Two-Dimensional Virtualized Grids
Permalink to "Two-Dimensional Virtualized Grids"Grids with hundreds of columns and tens of thousands of rows — financial models, sensor matrices, spreadsheet-like tools — virtualize in both directions. Only the visible block of cells exists in the DOM. That makes them fast and makes them confusing to assistive technology: screen readers see a small table and announce “column 3 of 8” when the user is in column 143 of 400. Making them accessible means describing the full grid with counts and indexes, keeping the focused cell and its headers rendered and driving navigation from data coordinates.
This guide belongs to accessible virtualized list patterns.
Spec reference
Permalink to "Spec reference"- aria-rowcount / aria-colcount — total rows and columns including headers.
- aria-rowindex / aria-colindex — 1-based positions of rendered rows and cells.
- ARIA grid pattern — arrow keys, Home/End, Ctrl+Home/End and Page Up/Down navigate cells.
- SC 1.3.1 (A) — headers and positions programmatic.
When to use — and when not to
Permalink to "When to use — and when not to"Use 2D virtualization when column counts are large enough that rendering every column is slow (often above 50 to 100 columns with many rows).
Avoid it when columns are few; row-only virtualization is simpler and better supported by screen readers. The misapplication: virtualizing columns without aria-colindex, so every screen reader announces column numbers within the rendered block.
Annotated code example
Permalink to "Annotated code example"<div role="grid" aria-label="Sensor readings" aria-rowcount="10001" aria-colcount="401">
<div role="row" aria-rowindex="1">
<div role="columnheader" aria-colindex="1">Sensor</div>
<div role="columnheader" aria-colindex="142">12:21</div>
<div role="columnheader" aria-colindex="143">12:22</div>
</div>
<div role="row" aria-rowindex="5021">
<div role="rowheader" aria-colindex="1">S-5020</div> <!-- row header always rendered -->
<div role="gridcell" aria-colindex="142" tabindex="-1">21.4</div>
<div role="gridcell" aria-colindex="143" tabindex="0">21.6</div> <!-- focused cell -->
</div>
</div>
function moveFocus(dRow, dCol) {
const r = clamp(pos.row + dRow, 1, ROWS), c = clamp(pos.col + dCol, 1, COLS);
ensureRendered(r, c); // scroll the virtualizers so the cell exists
requestAnimationFrame(() => { cellAt(r, c).focus(); pos = { row: r, col: c }; });
}
// Keys: arrows ±1, PageUp/Down ±visible rows, Home/End row start/end, Ctrl+Home/End grid corners
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Effect | Announcement (NVDA) |
|---|---|---|
| Right Arrow | Next column (renders if needed) | “12:22, 21.6” |
| Ctrl+End | Last row and column | “row 10001, column 401, …” |
| Page Down | Down one viewport | Row header and value |
| Home | First column (row header) | “S-5020” |
| Screen reader table nav | Moves within rendered cells | Positions from indexes |
Step-by-step implementation
Permalink to "Step-by-step implementation"- Set counts on the grid. Include header rows and the row-header column in the totals.
- Index everything rendered. Every rendered row gets
aria-rowindex; every rendered cell getsaria-colindex, because cells are not contiguous across the full grid. - Pin headers. Always render the header row for visible columns and the row-header cell for visible rows, so header association works.
- Keep the focused cell alive. If the user scrolls with the mouse, keep the focused cell rendered (even if off-screen) so focus is not lost.
- Navigate in data space. Keyboard handlers compute target coordinates, ask the virtualizers to render them, then focus.
Integration context
Permalink to "Integration context"Index details are covered in aria-rowindex and aria-colindex in practice and counts in aria-colcount and aria-rowcount for partial grids. Library-specific notes are in TanStack Virtual accessibility. DOM budgets are discussed in reducing nodes per cell and measuring accessibility tree cost.
Gotchas
Permalink to "Gotchas"Screen reader table commands. NVDA’s Ctrl+Alt+Arrow moves within the DOM; at the edge of the rendered block it stops. Make sure the grid’s own arrow handling (focus mode) is the main path, and document it.
Sticky row headers. When columns scroll horizontally, keep the row-header column sticky and rendered.
Selection ranges. Selections must be stored as coordinate ranges, not DOM nodes.
Edge cases
Permalink to "Edge cases"- Frozen columns. Frozen columns are always rendered and keep their real
aria-colindex. - Column groups. Multi-level headers in virtualized columns require rendering group headers that span visible columns.
- Resizing columns. Recompute which columns render after a resize without moving focus.
Worked example
Permalink to "Worked example"An energy company’s sensor grid had 400 time columns and 10,000 sensors. Column virtualization made it fast but NVDA users heard “column 4 of 14” while in the afternoon readings, and Ctrl+End jumped to the edge of the visible block. The team added counts and indexes, kept row headers and the focused cell rendered, and routed Ctrl+Home/End through data coordinates. Users could navigate to “sensor S-5020 at 16:40” and hear the correct position.
Common mistakes
Permalink to "Common mistakes"- Missing aria-colindex on cells.
- Headers not rendered for visible columns.
- Focused cell unmounted on scroll.
- Navigation based on DOM neighbours.
Testing checklist
Permalink to "Testing checklist"Testing it with a screen reader
Permalink to "Testing it with a screen reader"With NVDA, focus a cell and move right past the rendered block. Listen for column numbers rising beyond the block size and for correct headers. Press Ctrl+End and confirm the announced position matches the counts. Scroll with the mouse wheel away from the focused cell, then press an arrow key; focus should continue from the same cell. Repeat with JAWS. In the accessibility tree, verify indexes on a cell deep in the grid.
Performance budget
Permalink to "Performance budget"Measure frame times while holding an arrow key in each direction. Rendering a new column requires creating a cell for every visible row, so horizontal moves cost more than vertical ones; keep cells lightweight and reuse elements. A good target is under 16 milliseconds per move on mid-range hardware, so screen reader announcements are not delayed by rendering. If frames regularly exceed that, reduce the overscan or simplify cell content before considering larger architectural changes.
Go to cell
Permalink to "Go to cell"In a grid with four million cells, arrow keys alone are not enough. Add a “Go to” command (Ctrl+G is a common choice when it does not conflict) that opens a small dialog with row and column fields, or accepts a reference such as “S-5020, 16:40”. On submit, render the target block, move focus to the cell and announce its position and headers. Return focus to the grid if the dialog is cancelled. This helps everyone, but it matters most for screen reader users, who cannot scan a large grid visually to find a region of interest.
Design system notes
Permalink to "Design system notes"Use a grid component that handles 2D virtualization with indexes, pinned headers and focus retention built in; avoid composing two separate virtualizers by hand.
FAQ
Permalink to "FAQ"Do screen readers support aria-colindex?
NVDA and JAWS use it for announcements; VoiceOver support is partial.
Should I render overscan cells?
Yes, a few rows and columns beyond the viewport smooth navigation.
Can I use a native table?
Yes, with aria-rowindex on tr and aria-colindex on cells.
How do I handle select-all?
As a range covering all data, not rendered cells.
Is 2D virtualization always faster?
For wide grids, yes; for narrow ones, row virtualization is enough.
How do I test performance?
Measure frame times during keyboard navigation and DOM node counts.
Related
Permalink to "Related"- aria-rowindex and aria-colindex in practice — positions
- Reducing nodes per cell — lighter cells
- TanStack Virtual accessibility — a common library