content-visibility: auto for Long Animated Lists
Part of Paint Invalidation & Repaint Boundaries in Performance Budgeting & GPU Architecture.
The problem
A feed with several hundred rows, each with an avatar, a body and a reveal animation, is slow before anything animates. The initial style and layout pass processes every row whether or not it is on screen, the first paint rasterises everything within the tile grid, and any global change — a theme switch, a viewport resize — re-runs all of it.
Virtualising the list in JavaScript solves it and costs a scroll listener, a windowing library, a spacer element, and a class of bugs where the browser’s own scroll anchoring fights the library’s. content-visibility: auto gets most of the same benefit from one declaration.
Root cause: the browser renders what it might have to show
Rendering work is proportional to the number of elements in the box tree, not to the number visible. A row 4,000 pixels below the fold still has its styles resolved, its layout computed, and — if it falls inside a rasterised tile — its pixels painted.
content-visibility: auto changes the contract. It tells the browser that the element’s subtree does not need to be rendered until it is relevant, which means near the viewport, focused, selected, or matched by find-in-page. Until then, the subtree is skipped: no style resolution for descendants, no layout, no paint. The element itself still participates in layout, using contain-intrinsic-size as its placeholder dimensions.
The catch is in that last sentence. A skipped row reports whatever size you declared, so the scroll height of the whole list is a sum of guesses. Guess badly and the scrollbar lurches every time a batch of rows resolves to its true height. That is the entire practical difficulty of this feature, and the auto keyword — contain-intrinsic-size: auto 96px — solves most of it by remembering each element’s last measured size.
Step-by-step resolution
Production code pattern
/* Intent: a long feed where off-screen rows cost nothing, and rows animate in
as they become rendered, with no windowing library and no scroll listener. */
.feed__row {
/* Skip rendering entirely until the row is near the viewport. Implies
contain: layout paint style on the skipped subtree. */
content-visibility: auto;
/* Placeholder size while skipped. `auto` means: use the last measured
height once this row has been rendered at least once. */
contain-intrinsic-size: auto 96px;
}
/* The entry animation is declared on the row, so it plays when the row
becomes rendered rather than when a script notices it. */
.feed__row {
animation: row-in 260ms cubic-bezier(0, 0, 0.2, 1) both;
animation-timeline: view();
animation-duration: auto;
animation-range: entry 0% entry 80%;
}
@keyframes row-in {
from { opacity: 0; translate: 0 8px; }
}
/* Accessibility gate: rows appear at rest. The skipping still applies —
it is a rendering optimisation, not motion. */
@media (prefers-reduced-motion: reduce) {
.feed__row {
animation: none;
animation-timeline: auto;
opacity: 1;
translate: 0 0;
}
}
/* Progressive enhancement: an engine without support renders everything,
which is correct, just slower. No fallback needed for correctness. */
Rendering Impact: none for skipped rows — no style, layout, paint or composite. Rows entering the viewport pay one layout and one paint, then animate on the compositor via their own view timeline.
Pairing content-visibility with a view() timeline is a natural fit, because both are driven by the same underlying question — is this element near the viewport? The row becomes rendered slightly before it is visible, which gives its entry animation a frame or two to be ready, and the timeline then drives the reveal from the row’s own position rather than from a trigger.
Verification checklist
Constraints and trade-offs
- Inaccurate
contain-intrinsic-sizeproduces scroll jumps, which are more annoying than the render cost you saved. - Skipped subtrees cannot be measured by script;
getBoundingClientRect()on a descendant returns zeros until the row is rendered. - The
autokeyword remembers sizes per element, so a virtualised list that reuses DOM nodes for different content defeats it. - Anchor links into a skipped row work, but a script that scrolls to an element by measuring it first will not.
content-visibility: hiddenlooks like an optimisation and is a content-removal; never reach for it to hide something a user might search for.
Frequently asked questions
Does content-visibility break find-in-page or accessibility?
Not in the auto form. Skipped subtrees stay in the accessibility tree and remain searchable; the browser renders them on demand when a match or focus lands inside. content-visibility: hidden is the form that removes content from both, and it should never be used for content a user might need.
Why does my scrollbar jump when I scroll quickly?
Because contain-intrinsic-size is inaccurate. Skipped rows report the placeholder size, and when they resolve to a different real height the scroll position shifts. Use the auto keyword so measured sizes are remembered, and set the fallback length close to the real average.
Do entry animations still run on skipped rows?
They run when the row becomes rendered, which is as it approaches the viewport — usually exactly when you want the reveal. What does not work is a script that tries to animate a row while it is still skipped, because the row has no layout at that point.
Measuring whether it actually helped
content-visibility is easy to apply and easy to over-credit, because the initial render improvement is dramatic while the steady-state improvement can be zero. Three measurements separate the two.
Initial render. Record a Performance trace of a cold load and read the total time in Rendering and Painting before first contentful paint. This is where the win is largest, and it scales with how many rows are off-screen — a list of twenty gains almost nothing, a list of four hundred gains most of its render cost.
Scroll steady state. Record while scrolling at a constant speed and look for a per-frame cost that did not exist before. Rows resolving as they approach the viewport do real work, and if contain-intrinsic-size is badly wrong that work includes a layout correction for everything below. A smooth trace means the sizing is close enough; a sawtooth of layout spikes means it is not.
Memory. Tick the Memory checkbox and compare the GPU line. Skipped rows are not rastered, so a long list with promoted rows should show a much lower plateau, which is often a bigger practical win than the render time on a memory-constrained phone.
If none of the three moves, the list was not long enough to need this and the declaration is complexity without benefit — remove it rather than leaving it as folklore.
Related
- Paint Invalidation & Repaint Boundaries — the parent guide on scoping rendering work
- Using contain to Scope Repaints — the weaker boundary for content that is always on screen
- Reveal on Scroll Without IntersectionObserver — the view timeline that pairs with skipped rendering