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.

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