CSS content-visibility: Virtualize Long Lists Without a Single Line of JavaScript

Every frontend developer has hit this wall. You have a list — products, messages, logs, search results — and at some point it grows long enough that the page starts to crawl. The usual playbook is to reach for a virtual scroller: react-window, TanStack Virtual, a custom IntersectionObserver hack that took you two days to get right. All that complexity to solve a rendering problem.

Here’s the thing: the browser has been quietly shipping a solution right inside CSS since 2020. It’s called content-visibility, and most teams still haven’t heard of it.

This isn’t a magic bullet that replaces every use case for JavaScript virtualization. But for a huge slice of real-world long-list problems, it cuts render time dramatically with a single CSS declaration and no framework dependency at all.

Let’s actually understand what it does, where it wins, and where it’ll bite you.


What the Browser Does When It Renders a List

Before touching the property itself, it’s worth being precise about what actually costs time.

When you load a page with 5,000 list items, the browser does all of this for every single item, including the 4,900 you can’t see:

  1. Style calculation — matching CSS rules to each DOM node
  2. Layout — computing exact dimensions and position of each element and its children
  3. Paint — recording draw commands for each element
  4. Composite — assembling layers and pushing to the GPU

Layout is particularly brutal for long lists because it’s recursive. A child’s size can affect its parent, which affects siblings. The browser serializes most layout work, so a 5,000-item list can block the main thread for hundreds of milliseconds before the user sees anything interactive.

JavaScript virtualizers solve this by keeping the DOM small — only rendering the ~20 items in the viewport plus a small buffer. The trade-off is complexity: you need to track scroll position, calculate item heights, manage overscan, and handle dynamic content carefully.

content-visibility takes a different approach: it lets you keep the full DOM, but tells the browser it’s allowed to skip rendering work for off-screen elements.


The Property Itself

.list-item {
  content-visibility: auto;
}

Three possible values:

  • visible — the default. Normal rendering, no change.
  • hidden — permanently skip rendering. The element is invisible and doesn’t respond to user events. Useful for off-screen panels, not lists.
  • auto — skip rendering if off-screen, render when it scrolls into the viewport. This is what you want.

When an element has content-visibility: auto and it’s outside the viewport, the browser skips layout, paint, and style recalculation for it entirely. The element still exists in the DOM and is accessible to JavaScript and screen readers, but it contributes almost nothing to rendering cost.

The Google Chrome team published a case study showing a ~7x improvement in initial rendering time for a news aggregator page after applying this property. That number will vary wildly by content complexity, but the direction is always the same.


The Required Companion: contain-intrinsic-size

Here’s where most tutorials gloss over something critical, and it causes real bugs in production.

When the browser skips rendering an off-screen item, it doesn’t know how tall it is. So it assumes it’s zero. Suddenly your 5,000-item list has almost no scrollbar height, the scroll position jumps around as you scroll, and users think the page is broken.

The fix is contain-intrinsic-size. It gives the browser a placeholder size to use while the element’s content isn’t rendered:

.list-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 80px;
}

The auto keyword here is clever — it tells the browser to use the last-known rendered size of the element if one is available, falling back to the explicit value (80px) otherwise. This matters for dynamic content where items have different heights once loaded.

If your items are fixed-height (e.g., a simple message list), you can skip the auto keyword:

.list-item {
  content-visibility: auto;
  contain-intrinsic-size: 80px; /* shorthand for block-size only */
}

For variable-height content, always use auto <fallback>. The browser remembers the real sizes after first render and uses those on subsequent scrolls, so jump behavior smooths out after the first pass through the list.


A Real Setup

Here’s a minimal but complete implementation for a product list:

<ul class="product-list">
  <!-- Assume this is server-rendered or populated by your framework -->
  <li class="product-card">
    <img src="..." alt="Product name" width="200" height="200" loading="lazy">
    <h2>Product Name</h2>
    <p>Short description text here.</p>
    <span class="price">$49.99</span>
  </li>
  <!-- × 2000 more -->
</ul>
.product-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.product-card {
  /*
   * content-visibility skips off-screen rendering.
   * contain-intrinsic-size prevents layout collapse
   * while the item is not rendered. 'auto' lets the
   * browser remember real sizes after first render.
   */
  content-visibility: auto;
  contain-intrinsic-size: auto 300px;

  /* Padding and border must be set here, not on children,
   * or the intrinsic size estimate will be off. */
  padding: 16px;
  border-bottom: 1px solid #eee;
}

That’s it. No JavaScript. No scroll event listeners. No ResizeObserver chains.


Measuring the Difference

Don’t take my word for it — measure it yourself. Open Chrome DevTools → Performance, record a page load before and after.

What to look for:

  • "Recalculate Style" tasks in the flame chart should shrink substantially
  • "Layout" task duration drops because off-screen items are excluded
  • Interaction to Next Paint (INP) improves because the main thread is less blocked during scrolling
  • Largest Contentful Paint (LCP) can improve if the LCP element is in the viewport and the browser can get to it faster by skipping work on the rest

A rough benchmark: on a 2,000-item list of moderately complex cards, you’ll typically see initial render time drop from ~800ms to ~100ms. The exact numbers depend on your content complexity and device class.


Gotchas

Gotcha #1: Elements must be block-level (or at least block-like)

content-visibility has no effect on inline elements. If your list items are display: inline or display: inline-block, switch them to display: block or display: grid/flex. Inline layout doesn’t participate in the containment model the property relies on.

Gotcha #2: position: sticky inside skipped elements breaks

Sticky positioning requires the browser to know the element’s height to calculate the sticky threshold. If the element is skipped, the browser doesn’t know its height, and the sticky child will behave erratically or not stick at all. Either don’t use sticky inside content-visibility: auto elements, or structure your sticky headers outside the skipped subtree.

Gotcha #3: find-in-page (Ctrl+F) triggers rendering

When a user searches the page, the browser has to actually render the off-screen items to search their text content. This is actually correct behavior — it shouldn’t skip real content — but it can cause a visible rendering burst while the browser catches up. For most use cases this is fine. For extremely long lists (10,000+), it might stutter.

Gotcha #4: Poorly estimated intrinsic sizes cause scroll anchoring fights

If your contain-intrinsic-size estimate is wildly off from actual content height, you’ll see the scrollbar thumb jump as items render. The auto keyword mitigates this on repeat visits within a session, but the first scroll through the list will have some churn. Set your fallback to a realistic median item height, not a guess.

Gotcha #5: Safari support lagged

content-visibility shipped in Safari 18 (released late 2024). If your user base skews heavily toward older iPhones, check your analytics before committing. The property degrades gracefully (the page works, just without the optimization), but verify the experience on the actual devices your users have.

Gotcha #6: Avoid applying it to elements that animate on mount

If a list item has a CSS animation that plays when it enters the viewport, content-visibility: auto will retrigger that animation every time the element re-renders as it scrolls in and out. Either remove the animation from such items or use the prefers-reduced-motion media query to gate it.


When content-visibility Isn’t Enough

This property handles rendering cost. It doesn’t reduce DOM size, memory usage, or the initial HTML parse time. If you’re dealing with:

  • Lists over ~10,000 items where just parsing the HTML takes seconds
  • Items that fetch data per-row via JavaScript (you can’t skip JS execution, only rendering)
  • Infinite scroll where content is loaded dynamically anyway
  • Complex per-item state management in a framework like React

…then you still want a proper JavaScript virtualizer. content-visibility and virtual scrolling aren’t mutually exclusive either — you can use both on different parts of a page, or even combine them if your virtualizer renders full DOM subtrees with fixed containers.

For server-rendered pages, CMS-driven content, static HTML with hundreds of items, or any situation where the full DOM is just there and you need to make it fast: this CSS property is often the right call.


Production-Ready Solution

Here’s a production pattern that handles variable-height items, respects user motion preferences, and degrades cleanly:

/*
 * Apply only to browsers that support the property.
 * @supports provides a clean fallback for older Safari.
 */
@supports (content-visibility: auto) {
  .list-item {
    content-visibility: auto;
    /*
     * 'auto' prefix: use last known size if available.
     * 120px: realistic fallback for our content.
     * Adjust this to your actual median item height.
     */
    contain-intrinsic-size: auto 120px;
  }
}

/*
 * Disable mount animations for virtualized items to prevent
 * them retriggering on every scroll-in.
 */
.list-item * {
  animation: none !important;
}

/*
 * Re-enable animations only for the first N items
 * that are in the viewport on load.
 */
.list-item:nth-child(-n+5) * {
  animation: revert !important;
}

And a small JavaScript snippet for the rare case where you need to know if a specific item is currently rendered (e.g., to attach event listeners that require layout):

/**
 * content-visibility: auto elements report isRendered via
 * the Intersection Observer if they're currently in viewport,
 * but this is a simpler check for specific scenarios.
 */
function isItemRendered(element) {
  // Elements skipped by content-visibility have zero painted area
  // but non-zero offsetHeight (from contain-intrinsic-size).
  // Check the bounding rect visibility instead.
  const rect = element.getBoundingClientRect();
  return (
    rect.bottom >= 0 &&
    rect.top <= window.innerHeight
  );
}

The Containment Model Under the Hood

If you want to understand why this works (and when it can’t), it helps to know the underlying CSS Containment spec.

content-visibility: auto implicitly applies contain: style layout paint to the element when it’s off-screen. CSS containment tells the browser that the element’s internal layout doesn’t affect anything outside it. This is what makes it safe to skip — the browser knows no external element depends on the skipped subtree for its own layout.

When the element comes into view, the containment is still there but rendering is no longer skipped. The browser can now do layout and paint for that element in isolation, which is also faster than participating in global layout.

You can use contain independently of content-visibility for other performance wins (complex components with lots of internal state changes), but for the scrolling list use case, content-visibility: auto gives you the right combination automatically.


Summary

content-visibility: auto is a legitimate rendering optimization that belongs in any frontend developer’s toolkit. It works because browsers have gotten good at skipping rendering for off-screen content — they just needed a CSS hook to know when it’s safe to do so.

The property won’t replace JavaScript virtualization for truly massive datasets or dynamic data-fetching scenarios. But for the common case — a long, statically-rendered list that’s killing your LCP or causing janky scrolling — it’s often faster to implement, less fragile to maintain, and introduces no JavaScript bundle overhead whatsoever.

One CSS rule. Pair it with a sensible contain-intrinsic-size estimate, watch your layout flame charts shrink, and move on to the next problem.

👁 Views: 114,939 · Unique visitors: 45,636