Reducing Nodes per Cell
Permalink to "Reducing Nodes per Cell"A 1,000-row table with 10 columns has 10,000 cells. If each cell contains six nested elements — a wrapper, a flex container, an icon, a span for text, a tooltip trigger, a hidden tooltip — that is 60,000 elements before the table chrome. Screen readers build an accessibility tree from all of it and slow down noticeably: NVDA and JAWS can take seconds to load such pages and lag on every key press. Reducing nodes per cell is often the cheapest accessibility performance win.
This guide belongs to DOM size limits and performance trade-offs.
Spec reference
Permalink to "Spec reference"- SC 4.1.2 (A) — semantics must remain correct after simplification.
- SC 1.3.1 (A) — header relationships unchanged.
- Accessibility tree — each element can create a node; generic wrappers are often pruned but still cost processing.
- Lighthouse DOM size audit — flags pages with more than about 800 body elements and treats more than 1,400 as excessive.
When to use — and when not to
Permalink to "When to use — and when not to"Apply this to any table or grid with more than a few hundred rows rendered, or when screen reader users report lag. Combine with pagination or virtualization for very large data.
Do not remove elements that carry semantics: headers, buttons, links, inputs and their labels. The misapplication: replacing buttons with styled spans to save nodes, breaking keyboard and screen reader access.
Annotated code example
Permalink to "Annotated code example"<!-- Before: 7 elements -->
<td><div class="cell"><div class="cell-inner"><span class="icon"><svg viewBox="0 0 20 20">…</svg></span>
<span class="text">Overdue</span><div class="tooltip" hidden>Due 2 March</div></div></div></td>
<!-- After: 1 element, decoration in CSS, tooltip shared -->
<td class="status status-overdue" data-tip="Due 2 March">Overdue</td>
td.status::before { content: ""; display: inline-block; inline-size: .6em; block-size: .6em;
border-radius: 50%; margin-inline-end: .4em; background: currentColor; } /* decorative dot */
td.status-overdue { color: var(--status-danger); }
// One tooltip for the whole table
const tip = document.getElementById('table-tip');
table.addEventListener('focusin', e => showTip(e.target.closest('[data-tip]')));
table.addEventListener('mouseover', e => showTip(e.target.closest('[data-tip]')));
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Change | Screen reader effect |
|---|---|
| Remove wrappers | No change to announcements; faster navigation |
| Status icon to CSS with text | “Overdue” still read; icon not announced |
| Shared tooltip | Description via aria-describedby on focused element only |
| Shared row menu | Menu button per row remains; menu content shared |
| Fewer nodes overall | Faster page load in browse mode |
Step-by-step implementation
Permalink to "Step-by-step implementation"- Measure. Count elements per cell and total nodes (
document.querySelectorAll('*').length) and record screen reader load time. - Flatten. Remove wrappers created by component abstractions; apply layout to the cell itself.
- Move decoration to CSS. Pseudo-elements and backgrounds for dots, bars and icons that duplicate text.
- Share overlays. One tooltip element, one row menu and one popover per table, positioned on demand.
- Re-measure. Compare node counts and screen reader responsiveness before and after.
Integration context
Permalink to "Integration context"Measuring tree cost is covered in measuring accessibility tree cost, and budgets in setting a DOM node budget in CI. When reduction is not enough, see pagination versus virtualization for large tables and two-dimensional virtualized grids. Component library defaults are compared in React data grid libraries.
Gotchas
Permalink to "Gotchas"CSS content is sometimes read. Text in ::before content can be announced; use empty content for decorative shapes, or alternative text syntax where supported.
Icon-only meaning. If an icon conveys meaning without text, it needs a text equivalent; do not remove it without adding text.
Framework keys. Flattening components can change keys and remount rows; test focus retention.
Edge cases
Permalink to "Edge cases"- Rich cells. Some cells genuinely need structure (avatar plus name plus role); keep them, but simplify neighbours.
- Editable cells. Render editors only when editing, not hidden in every cell.
- Third-party grids. Check configuration options for “lightweight cells” or custom renderers.
Worked example
Permalink to "Worked example"An HR system’s employee table rendered 800 rows with nine elements per cell from a design system’s cell component. JAWS took eight seconds to load the page and lagged on every arrow key. The team flattened the cell component, moved status dots to CSS, shared one tooltip and one row menu, and rendered editors on demand. Elements dropped from 72,000 to 9,000, JAWS loaded in under two seconds and navigation lag disappeared.
Common mistakes
Permalink to "Common mistakes"- Removing semantic elements.
- Decorative CSS content that is read aloud.
- Hidden editors in every cell.
- Not measuring before and after.
Testing checklist
Permalink to "Testing checklist"Testing it with a screen reader
Permalink to "Testing it with a screen reader"Load the page with NVDA and time how long until “document” is announced and browse mode is ready; repeat after changes. Navigate cells with table commands and listen for any newly announced decorative content, such as pseudo-element text. Confirm tooltips are read as descriptions when focus lands on cells with them. Repeat in JAWS, which is often more sensitive to DOM size.
Talking to design system teams
Permalink to "Talking to design system teams"Node bloat usually comes from shared components, so fixes belong there. Bring numbers: elements per cell, total nodes on a typical page and screen reader load time. Propose a lean cell variant for data-heavy tables and a shared overlay service. Design system teams are more receptive to measured, scoped changes than to a request to rewrite components, and the improvement then reaches every product using them.
Auditing a table quickly
Permalink to "Auditing a table quickly"To find where nodes come from, run a short script in the console: pick one row, count row.querySelectorAll('*').length, and list the tag and class of each descendant. Patterns jump out quickly — a wrapper per cell from a layout component, an SVG per status icon, hidden tooltip markup. Compare with the accessibility tree in browser developer tools to see which of those elements actually produce nodes. Fix the largest contributors first; removing one wrapper from every cell often saves more than any clever optimisation elsewhere.
Key points
Permalink to "Key points"Measure first, flatten wrappers, move decoration into CSS, share overlays across the table, keep every semantic element and measure again to confirm screen readers respond faster.
Design system notes
Permalink to "Design system notes"Provide a lean cell renderer and shared overlay services, and set a per-cell element budget in component guidelines.
FAQ
Permalink to "FAQ"Do generic divs affect screen readers?
They are often pruned from the accessibility tree but still cost DOM and layout work.
How many nodes per cell is reasonable?
One to three for most cells.
Will CSS pseudo-elements be read?
Text content may be; use empty content for decoration.
Should I virtualize instead?
Often both; lean cells make virtualization smoother too.
Do shared tooltips work with screen readers?
Yes, with aria-describedby set on the focused element.
How do I measure accessibility tree size?
See the measuring guide; browser devtools and scripts can count nodes.
Related
Permalink to "Related"- Measuring accessibility tree cost — measurement
- Lazy rendering offscreen dashboard widgets — deferring work
- Setting a DOM node budget in CI — enforcement
← Back to DOM Size Limits and Accessible Performance Tradeoffs