content-visibility: hidden for Cached Views
Part of Paint Invalidation & Repaint Boundaries in Performance Budgeting & GPU Architecture.
The problem
An analytics application has four heavy tabs, each with charts and long tables. Switching tabs with display: none discards the hidden tab’s rendering, so every switch costs a full layout and paint of thousands of elements — about 200ms, during which the switch animation stutters. Keeping all tabs rendered instead makes the initial load slow and every scroll expensive, because off-screen tabs still participate in rendering.
There is a middle option: rendered, but skipped.
Root cause analysis: three ways to hide, three costs
display: none removes the element from the box tree. Nothing about it is laid out, painted or retained, so it is the cheapest to keep hidden and the most expensive to reveal — everything is rebuilt from scratch.
Fully rendered but scrolled away costs style, layout and paint participation for content nobody sees.
content-visibility: hidden skips rendering the element’s contents while preserving their rendering state. The browser keeps what it needs to restore the subtree quickly, so revealing it again is much cheaper than rebuilding from display: none. The element’s own box remains, so it still takes part in its parent’s layout unless sized otherwise.
content-visibility: auto is different in intent: the browser decides, based on whether the content is near the viewport, and off-screen content remains findable and accessible. It is for long scrolling lists, as covered in content-visibility for long animated lists, not for hiding views.
The cost of the cache is memory. Retained rendering state for four heavy tabs is not free, and on a memory-constrained device the browser may discard it anyway.
Scroll position and form state persist because the elements are never removed from the DOM — a usability benefit that display: none also provides, but rebuilding does not preserve rendering work.
Step-by-step resolution
Production code pattern
.tabpanels { display: grid; }
.tabpanel {
grid-area: 1 / 1;
content-visibility: visible;
contain-intrinsic-size: auto 600px; /* remembered size while skipped */
opacity: 1;
transition:
opacity var(--duration-200) linear,
content-visibility var(--duration-200) allow-discrete;
}
.tabpanel[aria-hidden="true"] {
content-visibility: hidden; /* rendering skipped, state cached */
opacity: 0;
pointer-events: none;
}
/* Tabs beyond the cache limit are dropped entirely. */
.tabpanel[data-evicted] {
display: none; /* rebuilt from scratch if revisited */
}
@media (prefers-reduced-motion: reduce) {
.tabpanel { transition: none; }
}
const MAX_CACHED = 3;
const recent = [];
function activate(id) {
for (const panel of panels) {
const active = panel.id === id;
panel.setAttribute('aria-hidden', String(!active));
if (active) panel.removeAttribute('data-evicted');
}
// Keep a bounded cache of recently visited panels.
const i = recent.indexOf(id);
if (i !== -1) recent.splice(i, 1);
recent.unshift(id);
for (const old of recent.splice(MAX_CACHED)) {
document.getElementById(old)?.setAttribute('data-evicted', '');
}
}
Rendering Impact: no style, layout or paint for skipped panels; memory for their cached rendering state. Revealing a cached panel costs far less than rebuilding one that was
display: none.
The eviction step matters on memory-constrained devices: without it, a session that visits every tab retains all of them. Three cached panels covers the common “switch back and forth between two or three views” pattern while bounding the cost.
Accessibility and find-in-page
Content skipped with content-visibility: hidden is not rendered, so it is not exposed to assistive technology, not focusable, and not matched by the browser’s find-in-page. For inactive tab panels that is exactly right, and it matches what aria-hidden="true" declares. Keep the two in sync — as in the pattern above, where the attribute is both the styling hook and the semantic state — so there is no way for them to disagree.
Be careful applying the same technique to content users might expect to search, such as collapsed accordion sections containing documentation. content-visibility: auto keeps that content findable, and a browser that finds a match inside an auto subtree can scroll to it. Choose hidden only when the content is genuinely inactive.
Verification checklist
Constraints and trade-offs
- Cached rendering state consumes memory that a constrained device may reclaim anyway.
content-visibility: hiddencontent is not findable with find-in-page.- The element’s own box still participates in layout unless sized or positioned.
- Support for transitioning
content-visibilitywithallow-discreteis recent; older browsers snap. - Overlapping panels in one grid cell make the container as tall as the tallest cached panel unless sized.
Frequently asked questions
What is the difference between content-visibility: hidden and display: none?
Both hide content, but hidden preserves the subtree’s rendering state so showing it again is much cheaper, at the cost of memory.
Is content-visibility: hidden content accessible?
No. Its contents are not rendered, so they are not exposed to assistive technology or focus, which is correct for inactive views.
When should I use auto instead of hidden?
Use auto for long scrolling content that should remain findable and accessible, and hidden for views that are genuinely inactive.
Does the hidden panel still affect layout?
Its own box does. Use contain-intrinsic-size, or position panels so the container’s size does not depend on hidden ones.
Related
- Paint Invalidation & Repaint Boundaries — the parent topic
- Transitioning visibility and content-visibility — animating the switch
- content-visibility: auto for Long Animated Lists — the scrolling-content counterpart