components

VirtualizedTable

A <table> that only ever mounts the rows near the current scroll position. The two off-screen slices above and below the visible window each collapse into a single spacer row — with a real cell inside, so column widths stay correct — rather than every row existing in the DOM at once. This trades off variable row heights for simplicity: every row is the same rowHeight, which is what makes computing the visible slice from scroll position alone (no per-row measurement) correct.

aria-rowcount/aria-rowindex carry the true position of whatever's currently mounted — the standard pattern for a windowed native table — so a screen reader can still announce "row 480 of 10,000" even though row 1 isn't in the DOM.

tsx
IDName
1Row 1
2Row 2
3Row 3
4Row 4
5Row 5
6Row 6
7Row 7
8Row 8
9Row 9
10Row 10
11Row 11
12Row 12
13Row 13
14Row 14
15Row 15
<VirtualizedTable height={200} rowHeight={32} zebra />
height
rowHeight

When to reach for it

Table renders every row as-is — fine up to a few hundred rows. Past that, mounting every <tr> at once is what actually gets slow, not the data itself. VirtualizedTable fixes that by keeping the DOM small regardless of how many rows you hand it, at the cost of a fixed rowHeight and its own column/row API rather than Table's TableRow/TableCell children.

Examples

columns pairs a render function with each field — the same shape whether the table holds 50 rows or 50,000, since only the visible slice ever actually renders:

tsx
interface Person {
  id: number;
  name: string;
  role: string;
}

const columns: VirtualizedTableColumn<Person>[] = [
  { key: "name", header: "Name", render: (row) => row.name },
  { key: "role", header: "Role", render: (row) => row.role, width: 160 },
];

<VirtualizedTable
  columns={columns}
  rows={people}
  getRowKey={(row) => row.id}
  rowHeight={40}
  height={400}
/>;

onRowClick and selectedRowKey wire up row selection without re-rendering rows that aren't visible:

tsx
const [selectedId, setSelectedId] = useState<number | null>(null);

<VirtualizedTable
  columns={columns}
  rows={people}
  getRowKey={(row) => row.id}
  selectedRowKey={selectedId ?? undefined}
  onRowClick={(row) => setSelectedId(row.id)}
/>;

zebra stripes rows by their real data index — not their DOM position, which would drift once a spacer row starts absorbing scrolled-past rows:

tsx
<VirtualizedTable columns={columns} rows={people} getRowKey={(row) => row.id} zebra />

Props

PropTypeDefaultDescription
classNametext——
columns *readonly——
emptyMessagetext"No rows."—
getRowKeyreadonlythe row's index—
heightnumber400The scrollable viewport's height in px.
onRowClickreadonly——
overscannumber4Extra rows kept rendered past each edge of the viewport, to reduce blanking during a fast scroll.
rowHeightnumber40Every row's height in px — fixed, since windowing depends on it.
rows *readonly——
selectedRowKeyreadonly——
zebrabooleanfalseAlternating row background from the second row on.