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.
<VirtualizedTable height={200} rowHeight={32} zebra />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:
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:
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:
<VirtualizedTable columns={columns} rows={people} getRowKey={(row) => row.id} zebra />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| className | text | — | — |
| columns * | readonly | — | — |
| emptyMessage | text | "No rows." | — |
| getRowKey | readonly | the row's index | — |
| height | number | 400 | The scrollable viewport's height in px. |
| onRowClick | readonly | — | — |
| overscan | number | 4 | Extra rows kept rendered past each edge of the viewport, to reduce blanking during a fast scroll. |
| rowHeight | number | 40 | Every row's height in px — fixed, since windowing depends on it. |
| rows * | readonly | — | — |
| selectedRowKey | readonly | — | — |
| zebra | boolean | false | Alternating row background from the second row on. |