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.
What is rendered in a 2D virtual grid Layers: the full grid is 10,000 rows by 400 columns; the viewport renders a block of about 30 rows by 12 columns; the header row and row-header column are always rendered for that block; the focused cell is always kept rendered even when scrolled away. What is rendered in a 2D virtual gridFull data10,001 rows × 401 columns (with headers)Viewport block~30 rows × 12 columns renderedHeaders for blockcolumn headers and row headers renderedFocused cellalways rendered, even off-screen
Render the visible block, its headers and the focused cell.

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.

Positions without and with indexes Comparison: without counts and indexes, screen readers report positions within the rendered block; with them, positions match the full grid. Positions without and with indexes✗ "Column 3 of 12""Row 5 of 30"Ctrl+End stops at block edgeHeaders missing off-block✓ "Column 143 of 401""Row 5,021 of 10,001"Ctrl+End reaches last data cellHeaders rendered for 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
DOM nodes rendered by approach Bar chart of DOM cell nodes for a 10,000 by 400 grid: full render 4,000,000; row virtualization only 12,000; two-dimensional virtualization 360. DOM nodes rendered by approachFull rendercellsRows only (30 rows × 400 cols)cellsRows and columns (30 × 12)cells
Two-dimensional virtualization keeps the DOM tiny.

Step-by-step implementation

Permalink to "Step-by-step implementation"
  1. Set counts on the grid. Include header rows and the row-header column in the totals.
  2. Index everything rendered. Every rendered row gets aria-rowindex; every rendered cell gets aria-colindex, because cells are not contiguous across the full grid.
  3. Pin headers. Always render the header row for visible columns and the row-header cell for visible rows, so header association works.
  4. 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.
  5. 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.

Permalink to "Related"

← Back to Accessible Virtualized List Patterns