content-visibility: Offscreen Rendering Without List Virtualization
content-visibility: Offscreen Rendering Without List Virtualization
A list of a thousand comments under an article renders noticeably slowly, and scrolling through it stutters on a weaker phone. The standard React answer is virtualization — react-window or @tanstack/react-virtual — which renders into the DOM only the items that currently fit in the visible window, plus a small buffer. It works, but it comes with a cost that rarely gets mentioned when adopting it: elements outside the window aren't hidden, they don't exist in the DOM. Pressing Ctrl+F won't find text in comment #850 until you scroll to it manually. Printing the page shows only what happened to be rendered at the moment you triggered the print. A screen reader navigating by headings or landmarks skips content that physically isn't in the tree — that's not a bug in virtualization, it's an unavoidable consequence of it.
content-visibility attacks the same performance problem from a completely different angle: elements stay in the DOM, always, in full. The browser simply skips the work it would normally have to do for them — layout, paint, creating compositor layers — until the element gets close enough to the visible area for that work to be worth doing.
What "skips the work" actually means
content-visibility: auto on an element tells the browser to apply CSS Containment to it — exactly the same mechanism that, in the Container Queries article, let us avoid a feedback loop when querying a container's size. Here containment serves a different purpose: if an element is offscreen, the browser can safely assume that its internal layout and paint don't affect anything outside itself, so it can defer them entirely, without recalculating a single pixel inside.
css
.comment {content-visibility: auto;contain-intrinsic-size: auto 180px;}
contain-intrinsic-size isn't a cosmetic extra — without it, a skipped element would collapse to zero height, because the browser has nowhere to get its size from once it stops computing the layout inside. It's exactly the same problem as container-type: size without an explicit height — except here the consequence isn't a disappearing card, it's a jumping scrollbar, because the document's height suddenly changes every time another comment enters or exits skipped mode. The value auto 180px tells the browser two things at once: use 180px as the first approximation before the element has ever actually been rendered, and then remember its real, computed size and use that remembered value as a placeholder for every subsequent skip — so if the real comments have varying heights, the browser gets progressively better at guessing how much space to reserve, instead of rigidly sticking to one fixed number.
A trick you don't get with full virtualization: hidden until found
This is where content-visibility does something virtualization essentially cannot do — not just in practice, but in principle. An element in auto mode, even though it's skipped for rendering purposes, still has its text physically sitting in the DOM. The browser knows this and treats such an element as "hidden but matchable": when the user presses Ctrl+F and types a phrase that matches text inside a skipped element, the browser temporarily un-skips it, actually renders it, highlights the match, and scrolls to it — automatically, with no code from you at all. display: none never did this (Ctrl+F simply ignores it), and a virtualized list can't do it either, because the text you're searching for simply isn't in the DOM until you scroll there manually.
A practical example: a comment list with not one line of virtualization JS
jsx
const CommentList = ({ comments }) => (<ul className="comment-list">{comments.map((comment) => (<li key={comment.id} className="comment"><p className="comment__author">{comment.author}</p><p className="comment__body">{comment.body}</p></li>))}</ul>);export default CommentList;
css
.comment {content-visibility: auto;contain-intrinsic-size: auto 180px;padding: 12px 0;border-bottom: 1px solid #e5e5e5;}
The whole component renders all one thousand <li> elements into the DOM — no scroll-window logic, no calculating which elements happen to be visible, no library at all. The browser decides on its own which of the thousand comments are worth computing layout and paint for right now, and which ones effectively "don't exist" from a rendering standpoint, even though they're fully present in the DOM.
When it's not enough — and why virtualization still makes sense
content-visibility solves the cost of rendering — layout and paint. It doesn't solve a cost that often hurts more in a React app: each of those thousand <li> elements is still a real React component that mounts, hydrates, and may have its own hooks and effects. Skipping CSS rendering doesn't make those thousand component instances stop existing in the React tree, and it doesn't cut the JavaScript cost needed to create them. For simple, mostly static blocks of content (comments, paragraphs in a long article, table rows with no interactivity) that doesn't matter — the real cost was already in layout/paint, not in component logic. For lists made of heavy, interactive components — each with its own state, subscriptions, an expensive render — real virtualization, which removes unused instances from the React tree entirely, is still sometimes the only way to keep things smooth.
Pitfalls and best practices
contain-intrinsic-sizeis mandatory in practice, not optional. Skipping it leads to a jumping scrollbar and an incorrect document height every time an element transitions into skipped mode and back.content-visibility: hiddenis not the same asauto.hiddenskips rendering always, regardless of on-screen position, but unlikedisplay: noneit keeps the internal, computed layout state cached — showing the element again (e.g. switching a tab) is cheaper than afterdisplay: none, because the browser doesn't recompute everything from scratch. An element withcontent-visibility: hiddenis also correctly removed from the accessibility tree, just likedisplay: none— it's a safe choice for tabs and panels that get toggled frequently, not just a performance trick.- It's not a substitute for virtualization on lists of heavy components. Treat it as the first, cheaper tool for simple, mostly static lists — reach for real virtualization only once a profiler actually points to a JS/React-side cost, not just a layout cost.
- Check support before fully relying on "hidden but matchable."
content-visibility: autoon its own has broad support across all major engines today, but the "temporary reveal" behavior on Ctrl+F tends to be most polished in Chromium — treat it as a nice bonus, not as a guarantee you build an accessibility requirement on.
Conclusion
Virtualization and content-visibility look, at first glance, like they solve the same problem — a long list that renders too slowly — but they operate on entirely different floors of the building. Virtualization removes elements from the DOM and from the React tree completely, paying for it with the loss of Ctrl+F, printing, and part of accessibility navigation. content-visibility leaves the DOM fully intact and simply tells the browser to skip the expensive rendering work for whatever's currently invisible — in exchange you get a less aggressive optimization, but for free, without breaking anything the browser could already do with a full document. For most long, reasonably simple lists, this is the right first tool — virtualization stays in reserve for the cases where this genuinely isn't enough.