kyle.berry
Writing

Virtualization breaks screen readers. Here is the repair kit.

Windowed rendering deletes most of your table from the accessibility tree. The fixes are specific, cheap, and mostly unknown.

3 min read
  • accessibility
  • dashboards
  • engineering

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

Accounts dataset

Showing 18 rendered rows from 180 records.

Arrow keys move focus · Enter selects
Signal SupplyTeam · Nia
APAC
$97,247
97
Watch
2026-12-22
Quartz HealthScale · Sol
APAC
$97,137
47
Excellent
2026-10-08
Keystone HealthScale · Avery
North America
$95,874
86
Stable
2026-07-15
Harbor FoundryEnterprise · Jules
North America
$95,764
36
At risk
2026-05-01
Northstar FoundryTeam · Mina
APAC
$94,501
75
Excellent
2026-02-08
Signal LabsTeam · Nia
APAC
$94,391
25
Watch
2026-12-22
Cedar LabsEnterprise · Rowan
North America
$93,128
64
At risk
2026-09-01
Keystone StudioScale · Avery
North America
$93,018
14
Stable
2026-07-15
Atlas SystemsScale · Sol
APAC
$91,755
53
Watch
2026-04-22
Northstar AnalyticsTeam · Mina
APAC
$91,645
147
Excellent
2026-02-08
Ember SupplyTeam · Jules
North America
$90,382
42
Excellent
2026-11-15
Cedar WorksEnterprise · Rowan
North America
$90,272
136
At risk
2026-09-01
Quartz HealthTeam · Nia
APAC
$89,009
31
At risk
2026-06-08
Atlas FoundryScale · Sol
APAC
$88,899
125
Watch
2026-04-22
Harbor FoundryEnterprise · Avery
North America
$87,636
20
Watch
2026-01-01
Ember LabsTeam · Jules
North America
$87,526
114
Excellent
2026-11-15
Signal LabsTeam · Mina
APAC
$86,263
9
Stable
2026-08-22
Cedar WorksEnterprise · Avery
North America
$2,400
8
Excellent
2026-01-01
Select a row to pin its summary here.
component.tsx
'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.