content-visibility — the native virtualization you're not using
Off-screen sections still pay layout + paint cost on every frame. `content-visibility: auto` tells the browser to skip them. One line of CSS, no JS virtualization library, and it's been Baseline since September 2024. Here's what it does, when it wins, and where the catches are.
Scroll the window below. The same ten-section article renders two ways — with and without content-visibility: auto:
section {
content-visibility: auto;
contain-intrinsic-size: auto 120px; /* placeholder height so off-screen items
don't collapse the scrollbar */
}Every long article on the web has the same problem. Your CMS renders 20 sections. Only two are on screen at a time. The other 18 still get laid out, styled, and painted every frame. That's most of your main-thread work being spent on content the user can't see.
JS solves this — at the cost of a library, a measurement loop, and a handful of edge cases. For 90% of articles, CSS has a better answer. content-visibility: auto tells the browser to skip rendering any section that's far from the viewport. No library. No measurement. One CSS declaration.
This post walks the spec, the three values, and the places where the native option beats JS virtualization (and the few places it doesn't).
content-visibility: auto applies layout + style + paint containment to an element. When the element is off-screen, the browser skips its contents entirely. When the element nears the viewport, the browser renders it. Pair with contain-intrinsic-size to reserve placeholder space so the scrollbar doesn't jump. Baseline Widely Available since September 2024 (Chrome 85, Safari 18, Firefox 125). For uniform-height article sections, it replaces a JS virtualization library with one line of CSS. For non-uniform virtualization or lists with thousands of items, reach for react-virtuoso / tanstack-virtual.
The three values
content-visibility has three values. Each one behaves differently.
MDN's verbatim definitions:
visible— "No effect. The element's contents are laid out and rendered as normal." This is the default.auto— "The element turns on layout containment, style containment, and paint containment. If the element is not relevant to the user, it also skips its contents."hidden— "The element skips its contents. The skipped contents must not be accessible to user-agent features, such as find-in-page, tab-order navigation, etc."
auto is the one that matters for scroll performance. It gives you skip-when-off-screen semantics for free.
hidden is display: none with one difference: state (scroll positions, form values, video playback) is preserved. Toggle it back to visible and your tab-switcher returns to where it was. That's a nice property but a narrower use case.
What "relevant to the user" means
The spec uses deliberately fuzzy language. "Relevant to the user" is the browser's call. In practice it means:
- On screen, or within ~50% of a viewport height of it.
- Currently has focus (or a descendant does).
- Is in the browser's find-in-page result set.
- Has a selection inside it.
An element meeting any of those renders normally. One that doesn't gets its contents skipped. The browser re-checks on scroll, focus change, and viewport resize.
You can listen to the transition with the contentvisibilityautostatechange event. It fires when a section starts or stops being skipped. Useful for lazy-loading heavy data (maps, charts, video embeds) only when the container becomes visible.
contain-intrinsic-size fixes the scrollbar jumping
Skipped contents aren't laid out, so a column of skipped sections has no intrinsic height and the container collapses accordingly. Your scrollbar jumps as the user scrolls past unmeasured sections. The fix is to reserve a placeholder size:
section {
content-visibility: auto;
contain-intrinsic-size: auto 500px;
}The auto 500px pair means "use 500px as the placeholder, but when the section has been rendered once, remember its real size and use that instead." So the first-time scroll is approximate; subsequent scrolls are exact.
For uniform-height sections (article cards of the same shape), a single value works. For wildly different sizes, the auto half lets the browser learn per-section. Pick a placeholder close to the median real height.
When content-visibility: auto beats JS virtualization
Three scenarios where it's the clean win:
Long articles with many sections. A blog post with 20 <section> children, each with a chart and some prose. content-visibility: auto on each section saves layout + paint work for everything off-screen. No library, no per-row virtual list.
Product pages with long spec tables. A giant feature grid under the fold. Lazy-render it via content-visibility: auto on the table wrapper. First render skips the table entirely; it renders when the user scrolls down.
Image-heavy galleries under the fold. Pair with loading="lazy" on each image and content-visibility: auto on the gallery wrapper. The wrapper skips its own layout work; the images defer their fetch.
In all three, the DOM is the source of truth. The browser handles the skip/render cycle. No measurement loop, no scroll handler, no library dep.
When JS virtualization is still the answer
Two scenarios where content-visibility isn't enough:
Thousands of items. content-visibility skips the render for off-screen items, but the DOM nodes still exist. For 10,000 rows, that's 10,000 DOM nodes the browser still has to manage. Memory grows with itemCount, not viewportItemCount. Real virtualization (react-virtuoso, tanstack-virtual) keeps DOM node count proportional to the viewport. For under ~1,000 items, content-visibility wins on simplicity. Above that, reach for a library.
Non-uniform heights with aggressive scroll control. If you need precise "jump to item N" that lands on the exact pixel, contain-intrinsic-size estimates aren't enough. JS virtualization's measurement cache gives you pixel-accurate positioning; content-visibility is approximate until each row has been rendered once.
Covered in more depth in the virtualization post.
The accessibility caveat
content-visibility: auto keeps skipped content accessible via explicit UA features: find-in-page still searches it, tab order still walks it, focus and text selection still reach it. Screen-reader behaviour for skipped auto content isn't normatively pinned by the spec — implementations vary, so don't rely on off-screen auto content being announced identically to visible content. Setting content-visibility: hidden removes the contents from all UA features (find-in-page, tab order, focus, AX tree).
That means auto is the safer default. Use hidden only when you want the element truly gone from the a11y tree — e.g., a tab panel not currently selected. Don't reach for hidden as an optimization; it changes semantics.
Browser support
Baseline Widely Available since September 2024:
- Chrome / Edge 85 (August 2020) — earliest shipper, years ahead
- Safari 18 (September 2024) — the laggard
- Firefox 125 (April 2024)
Before Safari 18, Safari ignored the declaration. That's a graceful degradation — your page works fine, just without the skip optimization. No polyfill needed.
The one-line audit
Open a long article on your site. Open DevTools Performance. Record a scroll pass. Look at the "rendering" band — layout and paint work per frame. On a page without content-visibility, you'll see consistent work across every frame. On a page with it, you'll see work concentrated around the viewport with near-zero cost for far-off sections.
If your article template renders the full body eagerly, add one declaration:
article > section {
content-visibility: auto;
contain-intrinsic-size: auto 400px;
}Measure scroll performance before and after. On long articles with heavy content (charts, tables, code blocks), the difference is often 30–60% less layout work per frame.
Primary sources
- CSS Containment Level 2 — §4 content-visibility — the normative definition, all three values, interactions with containment.
- CSS Containment Level 2 — contain-intrinsic-size — the placeholder-size property that pairs with content-visibility.
- MDN — content-visibility — curated reference with verbatim spec text and browser-support tables.
- web.dev — content-visibility — Chromium team's deep-dive, with performance measurements.
- CSS stacking contexts post — note that
content-visibilitycreates a new stacking context (mentioned in the triggers list).