I wrote before about where dashboards fail on accessibility, and the virtualization section of that post kept nagging at me. It deserves its own treatment, because the failure is structural: virtualization works precisely by lying to the DOM, and the accessibility tree is built from the DOM.
Here is a virtualized table you can poke at while reading:
Virtualized table
'use client'
import { useMemo, useRef, useState } from 'react'
import type { KeyboardEvent, UIEvent } from 'react'
type Region = 'North America' | 'Europe' | 'APAC' | 'LATAM'
type Health = 'Excellent' | 'Stable' | 'Watch' | 'At risk'
type SortKey = 'account' | 'region' | 'mrr' | 'users' | 'health' | 'lastSeen'
type SortDirection = 'ascending' | 'descending'
type Align = 'left' | 'right'
interface TableRow {
id: string
account: string
plan: string
region: Region
mrr: number
users: number
health: Health
lastSeen: string
owner: string
}
interface Column {
key: SortKey
label: string
align: Align
}
const ROW_HEIGHT = 48
const VIEWPORT_HEIGHT = 304
const OVERSCAN = 5
const GRID_COLUMNS =
'minmax(146px, 1.2fr) minmax(132px, 0.95fr) minmax(84px, 0.6fr) minmax(56px, 0.45fr) minmax(92px, 0.65fr) minmax(96px, 0.7fr)'
const COLUMNS = [
{ key: 'account', label: 'Account', align: 'left' },
{ key: 'region', label: 'Region', align: 'left' },
{ key: 'mrr', label: 'MRR', align: 'right' },
{ key: 'users', label: 'Seats', align: 'right' },
{ key: 'health', label: 'Health', align: 'left' },
{ key: 'lastSeen', label: 'Last active', align: 'left' },
] as const satisfies readonly Column[]
const REGIONS = ['North America', 'Europe', 'APAC', 'LATAM'] as const
const HEALTH_STATES = ['Excellent', 'Stable', 'Watch', 'At risk'] as const
const OWNERS = ['Avery', 'Mina', 'Rowan', 'Sol', 'Jules', 'Nia'] as const
const ACCOUNT_PREFIXES = [
'Cedar',
'Northstar',
'Keystone',
'Signal',
'Harbor',
'Quartz',
'Ember',
'Atlas',
] as const
const ACCOUNT_SUFFIXES = [
'Works',
'Labs',
'Supply',
'Analytics',
'Foundry',
'Systems',
'Studio',
'Health',
] as const
const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
})
function pick<T>(items: readonly T[], index: number): T {
const item = items[index % items.length]
if (item === undefined) {
throw new Error('Expected non-empty mock data collection')
}
return item
}
function buildRows(): TableRow[] {
return Array.from({ length: 180 }, (_, index) => {
const id = `acct-${String(index + 1).padStart(3, '0')}`
const day = String(((index * 7) % 28) + 1).padStart(2, '0')
const month = String(((index * 5) % 12) + 1).padStart(2, '0')
// Spread MRR across a wide range (a prime-ish stride avoids the clustered,
// near-identical values that read as fake/generated data).
const mrr = 2400 + ((index * 1373) % 96000)
const users = 8 + ((index * 11) % 144)
return {
id,
// Offset the suffix per prefix cycle so the same prefix doesn't keep
// pairing with the same suffix (no "Cedar Works" three rows running).
account: `${pick(ACCOUNT_PREFIXES, index)} ${pick(ACCOUNT_SUFFIXES, index * 3 + Math.floor(index / 8))}`,
plan: index % 4 === 0 ? 'Enterprise' : index % 3 === 0 ? 'Scale' : 'Team',
region: pick(REGIONS, index * 2),
mrr,
users,
health: pick(HEALTH_STATES, index + Math.floor(index / 9)),
lastSeen: `2026-${month}-${day}`,
owner: pick(OWNERS, index * 5),
}
})
}
function healthClass(health: Health): string {
switch (health) {
case 'Excellent':
return 'text-(--color-accent)'
case 'Stable':
return 'text-(--color-fg)'
case 'Watch':
return 'text-(--color-fg-muted)'
case 'At risk':
return 'text-(--color-fg-subtle)'
}
}
function healthDotClass(health: Health): string {
switch (health) {
case 'Excellent':
return 'bg-(--color-accent)'
case 'Stable':
return 'bg-(--color-fg)'
case 'Watch':
return 'bg-(--color-fg-muted)'
case 'At risk':
return 'bg-(--color-fg-subtle)'
}
}
function sortValue(row: TableRow, key: SortKey): string | number {
switch (key) {
case 'mrr':
case 'users':
return row[key]
case 'health':
return HEALTH_STATES.indexOf(row.health)
case 'account':
case 'region':
case 'lastSeen':
return row[key]
}
}
function compareRows(a: TableRow, b: TableRow, key: SortKey): number {
const aValue = sortValue(a, key)
const bValue = sortValue(b, key)
if (typeof aValue === 'number' && typeof bValue === 'number') {
return aValue - bValue
}
return String(aValue).localeCompare(String(bValue), 'en-US')
}
function nextDirection(current: SortDirection): SortDirection {
return current === 'ascending' ? 'descending' : 'ascending'
}
function ColumnHeader({
column,
index,
sort,
onSort,
}: {
column: Column
index: number
sort: { key: SortKey; direction: SortDirection }
onSort: (key: SortKey) => void
}) {
const isSorted = sort.key === column.key
const isRight = column.align === 'right'
const glyph = isSorted ? (sort.direction === 'ascending' ? '↑' : '↓') : '↕'
return (
<div
role="columnheader"
aria-colindex={index + 1}
aria-sort={isSorted ? sort.direction : undefined}
className={isRight ? 'text-right' : 'text-left'}
>
<button
type="button"
onClick={() => onSort(column.key)}
className={`group flex w-full items-center gap-1.5 px-3 py-2.5 font-mono text-[10px] tracking-[0.12em] text-(--color-fg-subtle) uppercase transition-colors hover:text-(--color-fg) ${
isRight ? 'flex-row-reverse' : ''
}`}
>
<span>{column.label}</span>
<span
aria-hidden="true"
className={
isSorted
? 'text-(--color-accent)'
: 'text-(--color-fg-subtle) opacity-0 transition-opacity group-hover:opacity-100'
}
>
{glyph}
</span>
</button>
</div>
)
}
export default function VirtualizedTable() {
const rows = useMemo(() => buildRows(), [])
const [sort, setSort] = useState<{ key: SortKey; direction: SortDirection }>({
key: 'mrr',
direction: 'descending',
})
const [activeRowId, setActiveRowId] = useState(rows[0]?.id ?? '')
const [selectedRowId, setSelectedRowId] = useState<string | null>(null)
const [scrollTop, setScrollTop] = useState(0)
const scrollRef = useRef<HTMLDivElement | null>(null)
const sortedRows = useMemo(() => {
const directionMultiplier = sort.direction === 'ascending' ? 1 : -1
return [...rows].sort((a, b) => compareRows(a, b, sort.key) * directionMultiplier)
}, [rows, sort])
const activeIndex = Math.max(
0,
sortedRows.findIndex((row) => row.id === activeRowId),
)
const selectedRow = sortedRows.find((row) => row.id === selectedRowId) ?? null
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN)
const visibleCount = Math.ceil(VIEWPORT_HEIGHT / ROW_HEIGHT) + OVERSCAN * 2
const endIndex = Math.min(sortedRows.length, startIndex + visibleCount)
const totalHeight = sortedRows.length * ROW_HEIGHT
// The active row stays mounted even when scrolled out of the window:
// aria-activedescendant must always point at a real element, or screen
// readers lose their place the moment the row virtualizes away.
const renderRows = sortedRows
.slice(startIndex, endIndex)
.map((row, offset) => ({ row, index: startIndex + offset }))
const activeRow = sortedRows[activeIndex]
if (activeRow && (activeIndex < startIndex || activeIndex >= endIndex)) {
renderRows.push({ row: activeRow, index: activeIndex })
}
function scrollRowIntoView(index: number) {
const viewport = scrollRef.current
if (!viewport) return
const rowTop = index * ROW_HEIGHT
const rowBottom = rowTop + ROW_HEIGHT
const viewportBottom = viewport.scrollTop + viewport.clientHeight
if (rowTop < viewport.scrollTop) {
viewport.scrollTo({ top: rowTop, behavior: 'auto' })
} else if (rowBottom > viewportBottom) {
viewport.scrollTo({ top: rowBottom - viewport.clientHeight, behavior: 'auto' })
}
}
function moveActiveRow(nextIndex: number) {
const clampedIndex = Math.min(Math.max(nextIndex, 0), sortedRows.length - 1)
const nextRow = sortedRows[clampedIndex]
if (!nextRow) return
setActiveRowId(nextRow.id)
scrollRowIntoView(clampedIndex)
}
function onGridKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (sortedRows.length === 0) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
moveActiveRow(activeIndex + 1)
break
case 'ArrowUp':
event.preventDefault()
moveActiveRow(activeIndex - 1)
break
case 'PageDown':
event.preventDefault()
moveActiveRow(activeIndex + Math.floor(VIEWPORT_HEIGHT / ROW_HEIGHT))
break
case 'PageUp':
event.preventDefault()
moveActiveRow(activeIndex - Math.floor(VIEWPORT_HEIGHT / ROW_HEIGHT))
break
case 'Home':
event.preventDefault()
moveActiveRow(0)
break
case 'End':
event.preventDefault()
moveActiveRow(sortedRows.length - 1)
break
case 'Enter':
case ' ':
event.preventDefault()
setSelectedRowId(sortedRows[activeIndex]?.id ?? null)
break
}
}
function onScroll(event: UIEvent<HTMLDivElement>) {
setScrollTop(event.currentTarget.scrollTop)
}
function toggleSort(key: SortKey) {
setSort((current) => ({
key,
direction: current.key === key ? nextDirection(current.direction) : 'ascending',
}))
}
return (
<div className="mx-auto flex w-3xl max-w-full flex-col gap-3 text-sm">
<style>{`
@media (prefers-reduced-motion: no-preference) {
.virtual-row { transition: background-color 120ms ease; }
}
`}</style>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-mono text-[10px] tracking-[0.18em] text-(--color-fg-subtle) uppercase">
Accounts dataset
</p>
<p className="mt-1 text-xs text-(--color-fg-muted)">
Showing {renderRows.length} rendered rows from {sortedRows.length} records.
</p>
</div>
<div className="rounded-full border border-(--color-border) bg-(--color-surface) px-3 py-1 font-mono text-[10px] text-(--color-fg-subtle)">
Arrow keys move focus · Enter selects
</div>
</div>
<div
role="grid"
aria-label="Customer accounts"
aria-rowcount={sortedRows.length + 1}
aria-colcount={COLUMNS.length}
aria-activedescendant={activeRowId ? `row-${activeRowId}` : undefined}
tabIndex={0}
onKeyDown={onGridKeyDown}
className="overflow-hidden rounded-(--radius-card) border border-(--color-border) bg-(--color-surface) outline-none focus-visible:border-(--color-accent)"
>
<div
role="row"
aria-rowindex={1}
className="sticky top-0 z-10 grid border-b border-(--color-border) bg-(--color-surface) text-left"
style={{ gridTemplateColumns: GRID_COLUMNS }}
>
{COLUMNS.map((column, index) => (
<ColumnHeader
key={column.key}
column={column}
index={index}
sort={sort}
onSort={toggleSort}
/>
))}
</div>
<div
ref={scrollRef}
role="rowgroup"
onScroll={onScroll}
className="scrollbar-thin overflow-y-auto"
style={{ height: VIEWPORT_HEIGHT }}
>
<div className="relative" style={{ height: totalHeight }}>
{renderRows.map(({ row, index: rowIndex }) => {
const isActive = row.id === activeRowId
const isSelected = row.id === selectedRowId
return (
<div
id={`row-${row.id}`}
key={row.id}
role="row"
aria-rowindex={rowIndex + 2}
aria-selected={isSelected}
onMouseEnter={() => setActiveRowId(row.id)}
onClick={() => {
setActiveRowId(row.id)
setSelectedRowId(row.id)
}}
className="virtual-row absolute left-0 grid w-full cursor-default border-b border-(--color-border) text-(--color-fg-muted)"
style={{
gridTemplateColumns: GRID_COLUMNS,
height: ROW_HEIGHT,
transform: `translateY(${rowIndex * ROW_HEIGHT}px)`,
background: isActive
? 'color-mix(in srgb, var(--color-accent-soft) 42%, transparent)'
: undefined,
boxShadow: isSelected ? 'inset 3px 0 0 var(--color-accent)' : undefined,
}}
>
<div role="gridcell" aria-colindex={1} className="min-w-0 px-3 py-2">
<span className="block truncate text-(--color-fg)">{row.account}</span>
<span className="block truncate font-mono text-[10px] text-(--color-fg-subtle)">
{row.plan} · {row.owner}
</span>
</div>
<div role="gridcell" aria-colindex={2} className="px-3 py-3">
{row.region}
</div>
<div
role="gridcell"
aria-colindex={3}
className="px-3 py-3 text-right text-(--color-fg) tabular-nums"
>
{currencyFormatter.format(row.mrr)}
</div>
<div
role="gridcell"
aria-colindex={4}
className="px-3 py-3 text-right tabular-nums"
>
{row.users}
</div>
<div role="gridcell" aria-colindex={5} className="px-3 py-3">
<span className={`flex items-center gap-2 ${healthClass(row.health)}`}>
<span
aria-hidden="true"
className={`inline-block size-1.5 shrink-0 rounded-full ${healthDotClass(row.health)}`}
/>
{row.health}
</span>
</div>
<div
role="gridcell"
aria-colindex={6}
className="px-3 py-3 font-mono text-[11px]"
>
{row.lastSeen}
</div>
</div>
)
})}
</div>
</div>
</div>
<div className="min-h-10 rounded-md border border-(--color-border) bg-(--color-surface) px-3 py-2 font-mono text-[11px] text-(--color-fg-subtle)">
{selectedRow ? (
<span>
Selected {selectedRow.account}: {currencyFormatter.format(selectedRow.mrr)} MRR,
{` ${selectedRow.users}`} seats, {selectedRow.health.toLowerCase()} health.
</span>
) : (
<span>Select a row to pin its summary here.</span>
)}
</div>
</div>
)
}
What actually breaks
A windowed list renders maybe twenty of its ten thousand rows. Assistive tech sees exactly those twenty. The concrete symptoms:
The row count is a lie. A screen reader announces "row 12 of 20" for a table the sighted user experiences as ten thousand rows. The user has no idea the other 9,980 exist.
Sequential navigation hits a wall. "Jump to next row" works until the edge of the rendered window, then stops. From the user's chair, the table simply ends.
Find-in-page is dead. Ctrl+F only searches mounted DOM. So does "select all," so does reader mode, so does any browser extension that reads the page.
Focus evaporates. If focus is inside a row and the user scrolls it out of the window,
the row unmounts and focus falls back to <body>. For a keyboard user that is a teleport to
the top of the page with no explanation.
The repair kit
The first two problems have direct ARIA answers. Set aria-rowcount on the table to the
true total, and aria-rowindex on each rendered row to its true position in the full
dataset. Announcements become "row 47 of 10,000," and screen readers understand the table
extends beyond what is rendered. This is two attributes, and almost no virtualized table
ships them.
Focus loss needs a policy, and there are two defensible ones. Either pin the focused row so it never unmounts while it holds focus (my preference: one extra row rendered outside the window is cheap), or track the focused cell's coordinates and restore focus when the row remounts. What is not defensible is the default, where focus silently dies.
Find-in-page has no clean fix, so be honest about it and compensate: a real search input over the full dataset, visible and keyboard-reachable, does what Ctrl+F cannot. And below a few hundred rows, ask whether you need virtualization at all. Rendering 300 rows is fine. An escape hatch ("show all 300") is sometimes the most accessible feature on the page.
The test that catches all of this
Unplug your mouse, turn on VoiceOver or NVDA, and try to answer one question from your own table: "what is the last row?" If the honest answer is "I cannot get there," you have not shipped a table, you have shipped the first twenty rows of one.