Rethinking Frontend Development: When to Leverage CSS Over JavaScript

Frontend Development

Discover how modern CSS features like `content-visibility`, container queries, and scroll-driven animations can replace common JavaScript workarounds, leading to more performant and less complex web applications. Learn when CSS shines and when JavaScript is still essential.

A common knowledge gap often leads to over-engineering in web development, eventually impacting performance. Modern CSS features have evolved significantly, yet many developers still default to JavaScript for problems that CSS can now natively solve with greater efficiency.

Consider content-visibility: auto, which provides the same virtualization benefits as libraries like React-Window but with zero JavaScript and no bundle weight. Similarly, modern viewport units like dvh, svh, and lvh have resolved mobile height issues that were previously patched with window.innerHeight for years.

Both content-visibility and modern viewport units achieved over 90% global browser support in 2024 and are production-ready today. Despite this, developers frequently resort to JavaScript workarounds, often due to a lack of awareness regarding CSS's advancements.

This article aims to bridge that knowledge gap. We will examine benchmarks, discuss migration paths, and honestly assess situations where JavaScript remains the superior solution. Before diving in, it’s crucial to acknowledge that if you find yourself reaching for useEffect and useState to resolve a rendering issue, you might be overlooking a more direct CSS solution.

The React Virtualization Problem

React developers often consider virtualization libraries like react-window and react-virtualized as the go-to solution for rendering long lists. The underlying principle is sound: if a user only sees a handful of items at a time, why render all 1,000? Virtualization creates a small "window" of visible items, unmounting others as the user scrolls.

The problem isn't the concept of virtualization itself, but its premature and excessive application. Developers frequently use react-window for a product grid with 200 items or react-virtualized for a blog feed with 50 posts.

This has fostered a "cargo cult" mentality around list performance. Instead of verifying if the browser can handle the rendering natively, we immediately wrap everything in useMemo and useCallback, labeling it "optimized."

Here’s a typical minimal react-virtualized setup:

import { List } from 'react-virtualized';
import { memo, useCallback } from 'react';

const ProductCard = memo(({ product, style }) => {
  return (
    <div style={style} className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.price}</p>
      <p>{product.description}</p>
    </div>
  );
});

function ProductGrid({ products }) {
  // Memoize the row renderer to prevent unnecessary re-renders
  const rowRenderer = useCallback(
    ({ index, key, style }) => {
      const product = products[index];
      return <ProductCard key={key} product={product} style={style} />;
    },
    [products]
  );

  return (
    <List
      width={800}
      height={600}
      rowCount={products.length}
      rowHeight={300}
      rowRenderer={rowRenderer}
    />
  );
}

This implementation functions correctly, requiring approximately 50 lines of code, adding about 15KB to your bundle, and necessitating the configuration of item heights and container dimensions. It's a fairly standard approach.

However, React developers rarely stop here. Conditioned to pursue re-render optimizations, they often proceed to wrap everything in memoization and callbacks:

import { List } from 'react-virtualized';
import { memo, useCallback, useMemo } from 'react';

const ProductCard = memo(({ product, style }) => {
  return (
    <div style={style} className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.price}</p>
      <p>{product.description}</p>
    </div>
  );
});

function ProductGrid({ products }) {
  const rowCount = products.length;


  // Memoize the row renderer to prevent unnecessary re-renders
  const rowRenderer = useCallback(
    ({ index, key, style }) => {
      const product = products[index];
      return <ProductCard key={key} product={product} style={style} />;
    },
    [products]
  );

  // Memoize row height calculation
  const rowHeight = useMemo(() => 300, []);

  return (
    <List
      width={800}
      height={600}
      rowCount={rowCount}
      rowHeight={rowHeight}
      rowRenderer={rowRenderer}
    />
  );
}

Notice the useMemo(() => 300, []) — a memoized constant. The component is wrapped in memo() to prevent re-renders that likely weren’t occurring anyway. useCallback is introduced for a function that react-window already optimizes internally.

These practices are often adopted out of habit, not because a measurable performance problem was identified. While developers focused on hypothetical re-renders, CSS silently introduced a native solution: content-visibility. This property instructs the browser to skip rendering off-screen content, mirroring the concept of virtualization without JavaScript, scroll calculations, or manual item height configurations.

The true question isn't whether virtualization works (it does), but whether your specific list genuinely requires it. Most React applications manage lists containing hundreds of items, not tens of thousands. In these common scenarios, content-visibility delivers roughly 90% of the benefit with significantly less complexity.

What content-visibility Actually Does

The content-visibility property has three values: visible, hidden, and auto. For performance optimization, only auto is relevant.

When content-visibility: auto is applied to an element, the browser defers layout, style, and paint work for that element until it approaches the viewport. The browser intelligently begins rendering slightly before the element enters view, ensuring smooth scrolling. Once the element moves out of view, the browser pauses this work again.

The browser inherently understands what's visible, possesses viewport intersection APIs, and manages scroll performance. content-visibility: auto simply grants it explicit permission to optimize rendering by skipping unnecessary work.

Using content-visibility with the same product grid example would look like this:

function ProductGrid({ products }) {
  return (
    <div className="product-grid">
      {products.map(product => (
        <div key={product.id} className="product-card">
          <img src={product.image} alt={product.name} />
          <h3>{product.name}</h3>
          <p>{product.price}</p>
          <p>{product.description}</p>
        </div>
      ))}
    </div>
  );
}

And the CSS:

.product-card {
  content-visibility: auto;
  contain-intrinsic-size: 300px;
}

Just two lines of CSS. The contain-intrinsic-size property is crucial; it tells the browser how much space to reserve for off-screen content. Without it, the browser would assume these elements have zero height, disrupting the scrollbar's behavior. With it, scrolling remains consistent because the browser has an estimated size for the element, even when it’s not fully rendered.

This is just one instance where CSS has quietly adopted responsibilities traditionally handled by JavaScript. Another significant area is container-based responsive design.

The Container Query Problem

Historically, responsive design relied on media queries based on viewport width. This approach works well until a component is placed within a sidebar. A card component, for instance, might require different layouts depending on its parent container's width, not the overall screen width. A 300px-wide card in a sidebar should present differently than a 300px-wide card in the main content area, even if the viewport size is identical.

Developers often resorted to JavaScript for this, utilizing ResizeObserver to track container widths, toggling classes at various breakpoints, and forcing layout updates on every resize. Any component needing container-aware styling would involve JavaScript measuring its width and applying the appropriate styles.

Here’s a common JavaScript implementation for this problem:

function updateCardLayout() {
  const cards = document.querySelectorAll('.card');
  cards.forEach(card => {
    const width = card.offsetWidth;
    if (width < 300) {
      card.classList.add('card--small');
    } else if (width < 500) {
      card.classList.add('card--medium');  
    } else {
      card.classList.add('card--large');
    }
  });
}

const resizeObserver = new ResizeObserver(updateCardLayout);
document.querySelectorAll('.card').forEach(card => {
  resizeObserver.observe(card);
});

This entails over 20 lines of JavaScript to address what is fundamentally a CSS problem. It involves measuring DOM elements, managing observers, adding event listeners, and maintaining class state. The browser already knows the container width; developers were asking for it via JavaScript instead of allowing CSS to manage it directly.

CSS container queries shipped in all major browsers in 2023. They enable writing layout rules based on a parent container’s size, rather than the viewport.

.card-container {
  container-type: inline-size;
}

@container (min-width: 300px) {
  .card {
    display: grid;
    grid-template-columns: 1fr 2fr;
  }
}

@container (min-width: 500px) {
  .card {
    grid-template-columns: 1fr 1fr;
  }
}

With just three declarations, the browser natively recalculates container queries in the same efficient manner it handles media queries, without involving the main thread. Your card component automatically adapts to its container's width.

The container-type: inline-size property designates an element as a container whose children can query its inline size. Then, @container rules function similarly to @media rules, but they check the container’s dimensions instead of the viewport’s.

Browser support for container queries reached over 90% by 2025, with support in Chrome 105+, Safari 16+, and Firefox 110+. If you are still using ResizeObserver for component-based responsive design, you are solving a problem that CSS has already elegantly addressed.

The Scroll Animation Problem

Animations triggered when elements enter the viewport have traditionally been a JavaScript domain. To achieve a fade-in effect as a user scrolls, developers would set up an IntersectionObserver, monitor element visibility, add a class to initiate the CSS animation, and then unobserve the element to prevent memory leaks.

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('fade-in');
      observer.unobserve(entry.target);
    }
  });
});

document.querySelectorAll('.animate-on-scroll').forEach(el => {
  observer.observe(el);
});
.fade-in {
  animation: fadeIn 0.5s ease-in forwards;
}

@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

This approach is functional and has been the standard since IntersectionObserver debuted in 2019. It underpins nearly every parallax effect, fade-in card, and scroll-triggered animation.

The core issue is that JavaScript is being used to instruct CSS when to execute an animation based on scroll position. The browser already tracks scroll position and knows when elements enter the viewport. This setup creates an unnecessary bridge between two systems that could interact directly.

CSS scroll-driven animations allow you to link animations directly to scroll progress:

@keyframes fade-in {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.animate-on-scroll {
  animation: fade-in linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 30%;
}

The animation-timeline: view() property ties the animation's progress to how much of the element is visible in the viewport. The animation-range property dictates when the animation starts and ends based on scroll position. The browser manages all the complexities.

Crucially, these animations run on the compositor thread, not the main thread. IntersectionObserver callbacks, in contrast, execute on the main thread. If your JavaScript is busy rendering React components or processing data, IntersectionObserver callbacks can experience delays. Scroll-driven animations, however, maintain smooth performance because they do not compete with JavaScript execution.

Browser support for scroll-driven animations reached significant milestones in 2024, with Chrome 115+ (August 2023) and Safari 18+ (September 2024) providing full support. Firefox is currently implementing it behind a flag. This means roughly 75%+ coverage today, enabling a progressive enhancement strategy where IntersectionObserver serves as a fallback for older browsers.

The primary advantage is performance. Scroll-driven animations are declarative: you specify the animation and its trigger, and the browser optimizes its execution. With IntersectionObserver, you imperatively manage state, add classes, and hope your callback code is efficient.

When to Stick with JavaScript Workarounds

CSS is not a universal panacea. There are specific scenarios where JavaScript remains the appropriate tool, and acknowledging these limitations is essential for honest development.

Use JavaScript for virtualization when:

  • You have truly infinite lists with thousands of items. While content-visibility defers rendering, it still loads all data into the DOM. For lists with 1000+ items, this can become a memory bottleneck. Libraries like react-virtualized create DOM nodes only for visible items, thereby minimizing memory usage.
  • Your list items have variable or unknown heights that change after rendering. content-visibility relies on contain-intrinsic-size for proper functionality. If your items dynamically grow or shrink based on user interaction or loaded content, accurately calculating intrinsic sizes becomes challenging. Virtualization libraries are designed to handle this with sophisticated measurement APIs.
  • You require precise item tracking and scroll position control. If you're building a data table where users can navigate directly to row 5,000, or need to restore exact scroll positions across page loads, virtualization libraries offer the necessary APIs. content-visibility does not expose this level of granular control.

Use JavaScript for layout when:

  • Your logic depends on exact measurements. Container queries allow CSS to adapt based on size ranges, but if your application logic needs to know if a container is exactly 247px wide, you will still need ResizeObserver or getBoundingClientRect().
  • The layout itself is too dynamic for CSS. For interactive dashboards with draggable panels, resizable columns, and complex layout rules driven by application state and mathematical calculations, JavaScript is undeniably the correct domain.

Use JavaScript for animations when:

  • You need callbacks at specific animation points. Scroll-driven animations do not emit events when they start or end. If your animation needs to trigger data fetching or update application state, IntersectionObserver or traditional scroll event listeners remain necessary.

Conclusion

To summarize, here’s a straightforward decision framework for choosing between CSS and JavaScript. First, determine if CSS can solve the problem directly. If it can, use CSS. If not, consider a progressive enhancement approach: implement the modern CSS solution first, and provide a JavaScript fallback for older browsers. If this covers your use case, proceed with it. Only default to a JavaScript-first solution when CSS genuinely cannot accomplish the task.

The goal isn't to completely avoid JavaScript. It's to prevent reflexively reaching for JavaScript when CSS already provides an effective solution. Most lists do not contain thousands of items. Most animations do not require precise callbacks. Most components function perfectly well with container queries.

Understand the actual requirements of your UI. Measure real performance. Then, select the simplest tool that effectively addresses the problem. More often than not, that tool will be CSS.