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.
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.
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.
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.
Anchor links and :target
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 |