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).
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 |
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.
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.
Related
Permalink to "Related"- Measuring accessibility tree cost — the cost this does not remove
- Pagination versus virtualization — the alternatives
- Group header rows — natural blocks to contain
← Back to DOM Size Limits and Accessible Performance Tradeoffs