content-visibility: auto and Screen Readers

Permalink to "content-visibility: auto and Screen Readers"

content-visibility: auto is a CSS property that tells the browser it may skip style, layout and paint for an element’s contents while the element is off-screen. For long pages of data — a report with forty grouped tables, a table with thousands of rows split into row groups — it can cut rendering time dramatically. Unlike virtualization, the content stays in the DOM and, by specification, in the accessibility tree, so screen reader users can still reach everything.

That makes it attractive as an “accessible virtualization”. It is not quite that. This page explains what it improves, what it leaves unchanged, and the practical caveats for data pages. It belongs to DOM size limits and accessible performance tradeoffs.

Spec reference

Permalink to "Spec reference"

CSS Containment Level 2 defines content-visibility: visible | hidden | auto. With auto, the element gets layout, style and paint containment, and when it is not relevant to the user (off-screen, not focused, not selected, not found by find-in-page) its contents are skipped: not rendered. Skipped contents remain in the DOM, remain focusable, remain searchable by find-in-page (which makes them relevant), and remain in the accessibility tree.

contain-intrinsic-size gives skipped elements a placeholder size, so the scrollbar does not jump. The auto keyword (contain-intrinsic-size: auto 800px) remembers the last rendered size once the element has been shown.

By contrast, content-visibility: hidden skips contents and hides them from the accessibility tree, like display: none for AT purposes — a different tool entirely.

Criteria: none directly; the property must not break SC 1.3.1 (content stays exposed), SC 2.4.3 (focus order unaffected) or SC 2.4.11 (skipped content that gains focus must render).

content-visibility: auto versus virtualization Comparison of content-visibility auto against list virtualization on what each removes, what stays accessible, and the costs that remain. content-visibility: auto versus virtualizationcontent-visibility: autoSkips style, layout, paint off-screenDOM and accessibility tree unchangedEverything reachable by browse modeTree-size cost for screen readers remainsVirtualizationRemoves off-screen rows from the DOMAccessibility tree smallBrowse mode reaches only rendered rowsNeeds position metadata and focus handling
content-visibility saves rendering work; only virtualization or pagination shrinks the accessibility tree.

When to use it — and when not to

Permalink to "When to use it — and when not to"

Use it on long pages made of independent blocks: reports with many sections, grouped tables (one <tbody> per group), dashboards with many cards below the fold. The rendering savings are real and there is no accessibility downside when used correctly.

Do not expect it to fix screen reader slowness on a single huge table. The accessibility tree still contains every cell, screen readers still build buffers of the full content, and updates still flood events — which is the cost described in measuring accessibility tree cost for large tables.

Do not apply it to individual table rows. Containment on <tr> elements interacts badly with table layout (column widths computed from rendered rows only) and with some browsers’ table accessibility mapping. Apply it to row groups or wrappers.

The misapplication to name is using content-visibility: hidden for “collapsed” sections, believing content stays accessible. It does not: hidden removes contents from the accessibility tree.

Annotated code example

Permalink to "Annotated code example"
/* Long report: each section renders only when near the viewport */
.report-section {
  content-visibility: auto;
  /* stable scrollbar: estimated height until first render, then remembered */
  contain-intrinsic-size: auto 900px;
}

/* Grouped table: one tbody per group; contain the groups, not the rows */
.big-table tbody.group {
  content-visibility: auto;
  contain-intrinsic-size: auto 1200px;
}
// Verify content stays exposed: count accessible cells before and after scrolling
const cdp = await page.context().newCDPSession(page);
const count = async () => (await cdp.send('Accessibility.getFullAXTree'))
  .nodes.filter((n) => !n.ignored && n.role?.value === 'cell').length;

const before = await count();
await page.mouse.wheel(0, 20000);
const after = await count();
expect(before).toBe(after);          // skipped rendering must not change the tree
// Measure the rendering win (Chrome trace, before/after the CSS)
await page.tracing.start({ screenshots: false, categories: ['devtools.timeline'] });
await page.goto('/report?sections=40');
await page.tracing.stop();          // compare Layout and Paint totals

The accessibility check matters because browser support has evolved: early implementations had bugs where skipped content was missing from some accessibility APIs. Verify on your target browsers rather than trusting the specification alone.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Action Expected behaviour Watch for
NVDA/JAWS browse to a skipped section Content read normally Delay while it renders on demand
Tab to a link in a skipped section Section renders; link visible and focused Focus ring hidden if render is late
Find in page (Ctrl+F) Matches in skipped sections found Matches highlighted after render
VoiceOver rotor, headings Headings in skipped sections listed —
Scrollbar during reading Stable with contain-intrinsic-size: auto Jumps without it
content-visibility values and accessibility Matrix comparing content-visibility visible, auto and hidden on rendering of off-screen content, accessibility tree exposure and focusability. content-visibility values and accessibilityValueOff-screen renderingAccessibility treeFocusablevisibleRenderedExposedYesautoSkipped until relevantExposedYeshiddenSkipped alwaysNot exposedNo
auto and hidden differ exactly where accessibility is concerned.

Integration context

Permalink to "Integration context"

Grouped tables are the best fit in data interfaces: each <tbody> group from accessible group header rows is a natural containment boundary, and collapsing is still handled with the hidden attribute as in collapsing row groups with aria-expanded.

When the accessibility tree itself is too large, the options are in pagination versus virtualization for large tables. Enforce whichever you choose with a DOM node budget in CI.

What content-visibility: auto skips and keeps Layers of browser work for an off-screen section with content-visibility auto: skipped style, layout and paint; kept DOM, accessibility tree and focusability. What content-visibility: auto skips and keepsStyle recalculationskipped while off-screenLayout and paintskipped; contain-intrinsic-size holds spaceDOMkept — every element presentAccessibility treekept — every node exposedFocus and findkept — rendering resumes on demand
The top three layers are skipped; the bottom three — the ones assistive technology uses — are kept.

Gotchas

Permalink to "Gotchas"

Column widths. In auto-layout tables, skipped row groups do not contribute to column width calculation until rendered, so columns can shift as the user scrolls. Use table-layout: fixed or explicit widths.

Sticky headers. A sticky <thead> is unaffected, but sticky elements inside skipped groups will not stick until rendered.

Print. Printing renders everything; no issue, but very long reports may take a moment.

Design system notes

Permalink to "Design system notes"

A report layout component can apply content-visibility: auto to its sections by default, with an intrinsic-size estimate per section type. Keep it off table rows and interactive grids; document that it is a rendering optimisation, not a substitute for pagination when tables are large.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
Does content-visibility: auto hide content from screen readers?

No. Skipped content stays in the DOM and the accessibility tree, and remains focusable and searchable. Only its rendering is deferred. content-visibility: hidden is different and does remove content from the accessibility tree.

Can content-visibility replace virtualization for large tables?

Not for screen reader performance. It saves rendering work, but the accessibility tree still contains every cell, so screen readers face the same tree size and update cost.

Where should content-visibility be applied in a table?

On row groups or wrappers around large blocks, not on individual rows, and with table-layout fixed or explicit column widths so columns do not shift as groups render.

Why does the scrollbar jump with content-visibility?

Because skipped elements have no size by default. Set contain-intrinsic-size with the auto keyword and an estimate, so the browser uses the estimate until the element has rendered and then remembers its real size.

Permalink to "Related"

← Back to DOM Size Limits and Accessible Performance Tradeoffs