Using Content-Visibility for Offscreen Content

Applying content-visibility: auto to long lists and document sections tells the browser to skip style, layout, and paint for the parts that are offscreen, cutting initial rendering cost without writing a virtualization layer. This guide covers the application, the measured drop in rendering time, and the gotchas that bite. It builds on Content-Visibility and Rendering Subtrees, part of Layout and Paint Optimization.

Minimal Application

Apply the property to each repeating, independent block and give it an intrinsic-size estimate so offscreen items still reserve scroll height.

/* Before: the browser lays out and paints all 5,000 rows on load */
.feed-item { padding: 16px; border-bottom: 1px solid #eee; }

/* After: offscreen rows skip rendering until they near the viewport */
.feed-item {
  content-visibility: auto;            /* skip rendering work when offscreen */
  contain-intrinsic-size: auto 96px;   /* reserve height; `auto` remembers real size */
  padding: 16px;
  border-bottom: 1px solid #eee;
}

The browser renders only the items within a viewport-proximity margin and treats the rest as reserved boxes. As the user scrolls, sections render just in time. No JavaScript, no windowing library, no key management.

Rendered range versus reserved boxes A long list where only items inside the viewport-proximity band are fully rendered while offscreen items collapse to reserved intrinsic-size boxes. Full list in DOM reserved box (skipped) reserved box (skipped) rendered rows reserved box (skipped) reserved box (skipped) viewport + proximity margin Work performed per frame Style / layout / paint: only rows in the band Offscreen rows: contribute intrinsic-size only Scroll: next band renders just in time DOM untouched β€” no windowing, no keys contain: layout style paint is implied

The Mechanism

content-visibility: auto implies contain: layout style paint, so each item becomes its own layout and paint root. When an item is outside the rendering range, the engine skips recomputing its descendants’ styles, skips laying them out, and skips painting them β€” the subtree is still in the DOM but contributes only its reserved contain-intrinsic-size box to the page. When the item enters the range, the engine runs a one-time layout and paint for it. This is the same isolation as CSS contain property performance benchmarks measure, applied conditionally on viewport proximity. The same containment primitive is examined without the viewport gate in using contain: layout to isolate reflow scope.

State transition for a subtree A subtree moves from skipped to rendered as it crosses the proximity band, running a one-time layout and paint, then returns to skipped when it leaves. Skipped descendants unstyled enters band Rendering one-time style + layout one-time paint real height replaces estimate leaves band Skipped box height remembered contain-intrinsic-size holds scroll height in both skipped states

Measuring the Drop

Record a Performance trace on first load, before and after, under 6Γ— CPU throttling.

[Main Thread]  before content-visibility
└─ Layout (38.2ms)   β€” all 5,000 rows
   └─ Paint (21.4ms)
Initial render blocked: 59.6ms

[Main Thread]  after content-visibility
└─ Layout (4.1ms)    β€” ~30 visible rows
   └─ Paint (2.3ms)
Initial render blocked: 6.4ms   ← within 16.6ms budget

The initial Layout and Paint events shrink to the cost of what is on screen. Confirm with the Rendering β†’ Paint flashing overlay: only viewport-adjacent items repaint while you scroll.

Initial render cost before and after Horizontal bars comparing layout and paint durations before and after content-visibility, with the after case landing inside the frame budget. Before After Layout 38.2ms (5,000 rows) Paint 21.4ms 59.6ms blocked 4.1 Paint 2.3ms β€” ~30 visible rows Layout 4.1ms 16.6ms frame budget 6.4ms blocked β€” in budget

Gotchas

In-page search (Ctrl+F)

Browsers will reveal a content-visibility: auto subtree when find-in-page matches text inside it, because the find-in-page implementation forces rendering of matched offscreen content. This works, but it means a search can trigger a burst of layout as multiple sections render at once. It is correct behavior β€” do not try to defeat it β€” just be aware the page may shift as matches resolve.

Focus and tab order

Focusable elements inside a skipped subtree remain focusable. Tabbing or programmatic focus() into a skipped section forces it to render so the focused element can be displayed. Avoid auto-focusing deep into a long offscreen list on load, which would render everything up to that point.

Navigating to a #fragment inside a skipped subtree scrolls to it and forces that subtree to render. This works, but if you measure geometry immediately after setting location.hash, the layout may not be settled β€” defer the read, the same way you would for any forced synchronous layout.

Accessibility tree

Skipped subtrees still expose their content to assistive technology, so screen-reader navigation and document outline remain intact. Do not assume offscreen-skipped means hidden β€” it is not display:none.

Fix Pattern for Variable Heights

When item heights vary widely, a single estimate causes scroll-position drift. Set the estimate per row from a measured average, or rely on the auto keyword so the browser substitutes each row’s real last-rendered height after first render.

.feed-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 96px; /* `auto` overrides 96px once measured */
}

The detailed treatment of placeholder sizing and the scroll-anchoring shifts a bad estimate causes is in contain-intrinsic-size and scroll anchoring.

Verification Checklist

Metric Target How measured
Initial Layout duration < 4ms Performance panel
Initial Paint area Viewport-bounded Rendering β†’ Paint flashing
Items rendered on load ~viewport count, not total Trace node count
CLS from scrolling ≀ 0.1 Layout Instability API

Choosing Which Content to Skip

content-visibility: auto pays off in proportion to how much off-screen, independent content a page carries, so the first step is recognising good candidates. A long feed of cards, a documentation page of stacked sections, a comment thread β€” anything composed of self-contained blocks that stack vertically and do not need to paint outside their own bounds β€” is ideal, because each skipped block removes its style, layout, and paint from the initial pass. The savings scale with the number of such blocks below the fold, which on a long page can be most of the document.

The content to keep rendered is anything whose rendering escapes its box or is needed immediately: a sticky header, an element with a dropdown or tooltip that overflows, or content targeted by an in-page anchor you expect users to jump to. Because content-visibility implies contain: paint, overflowing children are clipped while the element is contained, and a jump to a deep anchor pays the deferred layout at that moment. Matching the property to genuinely independent, self-contained blocks β€” and pairing it with a contain-intrinsic-size estimate so the scrollbar stays honest β€” is what delivers the load-time win without the caveats biting.

Frequently Asked Questions

Does content-visibility: auto remove elements from the DOM?

No. The subtree stays in the DOM and remains reachable by querySelector, the accessibility tree, and in-page search. The browser only skips style, layout, and paint for it while it is offscreen, and each skipped subtree still contributes its contain-intrinsic-size box to scroll height. It is not display:none.

Why does my scrollbar jump when I use content-visibility?

The jump comes from a wrong contain-intrinsic-size estimate. When a row renders and its real height differs from the placeholder, the reserved box resizes and scroll position drifts. Use the auto keyword so the browser substitutes each row’s last-rendered height, and see contain-intrinsic-size and scroll anchoring for the sizing detail.

Should I put content-visibility on the scroll container or the items?

Put it on each repeating item, not the container. The container itself is usually onscreen, so containing it saves nothing, while containing each independent row lets the engine skip the offscreen majority. Apply the property to the smallest independent block that repeats.

Can content-visibility replace a virtualization library?

For long lists of independent blocks, often yes β€” it skips offscreen rendering with no JavaScript, no windowing, and no key management. It does not recycle DOM nodes, so extremely large lists still pay DOM-size costs in memory and query time; a windowing library that keeps only a few hundred nodes mounted still wins there.

Does an anchor link into a skipped subtree still work?

Yes. Navigating to a #fragment inside a skipped subtree scrolls to it and forces that subtree to render. If you read geometry immediately after setting location.hash, defer the read one frame so layout settles, the same as you would for any forced synchronous layout.