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.

Three ways to hide a viewhidden trades memory for a fast return.Three ways to hide a viewCost while hiddenCost to revealKeeps form and scroll statedisplay: noneNoneFull layout and paintYes (DOM kept)content-visibility:hiddenMemory for cached stateMuch cheaperYesRenderedoff-screenFull participationNoneYes
hidden trades memory for a fast return.

Step-by-step resolution

Caching heavy tabsHide with content-visibility, bound the cache, animate the switch.Caching heavy tabs1Measure the reveal cost of the heaviest tab with display: none.A baseline to beat2Switch inactive tabs to content-visibility: hidden.Rendering state retained3Add contain-intrinsic-size so the container does not collapse.Stable layout4Transition content-visibility with allow-discrete alongside opacity.A cross-fade instead of a snap5Keep only the two or three most recent tabs cached.Bounded memory6Confirm hidden tabs are not focusable or announced.
Hide with content-visibility, bound the cache, animate the switch.

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.

Time to switch to a heavy tabMid-range phone; 2,400-element panel with charts.Time to switch to a heavy tabRebuilt from display: none210 msRevealed from content-visibility: hidden34 msAlready rendered off-screen12 ms
Mid-range phone; 2,400-element panel with charts.

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: hidden content is not findable with find-in-page.
  • The element’s own box still participates in layout unless sized or positioned.
  • Support for transitioning content-visibility with allow-discrete is 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.