VirtualTable
Data table that windows its rows instead of paginating them, for large row counts. Same column model, sorting, and filtering as `Table`; use that one when pagination is the better fit.
import { VirtualTable } from '@elirobinson/react/components/organisms/VirtualTable';Styles: @elirobinson/react/styles/organisms/VirtualTable.css@elirobinson/react/styles/organisms/VirtualList.css@elirobinson/react/styles/organisms/table/core.css — already included when you import @elirobinson/react/styles.css.
200 rows, sortable headers, no pagination — scroll and only the rows near the viewport are in the DOM.
Show code
import type { ColumnDef } from '@elirobinson/react/components/organisms/VirtualTable';
import { VirtualTable } from '@elirobinson/react/components/organisms/VirtualTable';
type Order = {
id: string;
customer: string;
guide: string;
amount: string;
};
const CUSTOMERS = [
'Jordan Ellis',
'Priya Nair',
'Sam Okafor',
'Maria Gonzalez',
'Tomás Rivera',
'Aisha Bello',
'Liam Chen',
'Grace Kim',
'Noah Fischer',
'Ingrid Larsen',
];
const GUIDES = [
'Youth Football Fundamentals',
'Basketball Conditioning',
'Rugby Contact Basics',
'Athletics Sprint Mechanics',
'Netball Footwork Drills',
'Swim Stroke Technique',
];
function makeOrders(count: number): Order[] {
return Array.from({ length: count }, (_, index) => ({
id: `INV-${1000 + index}`,
customer: CUSTOMERS[index % CUSTOMERS.length],
guide: GUIDES[index % GUIDES.length],
amount: `$${(18 + (index % 5) * 6).toFixed(2)}`,
}));
}
const columns: ColumnDef<Order>[] = [
{ accessorKey: 'id', header: 'Invoice' },
{ accessorKey: 'customer', header: 'Customer' },
{ accessorKey: 'guide', header: 'Guide' },
{ accessorKey: 'amount', header: 'Amount' },
];
const orders = makeOrders(200);
export default function Basic() {
return <VirtualTable data={orders} columns={columns} height={360} rowHeight={44} />;
}When to use it
Use VirtualTable for a dataset large enough that pagination feels like busywork —
thousands of rows someone wants to scroll and sort through directly, not click through in
chunks of ten. It shares Table's column model, sorting, and filtering; the only thing
that changes is how the rows reach the screen — only the ones near the viewport are ever
rendered.
Reach for Table instead once pagination is the more natural fit — search results, an
order history someone might want to link straight to "page 3" of. Windowing and pagination
solve the same DOM-size problem two different ways, so VirtualTable doesn't also
paginate.
Filtering
Same filterable and filterPlaceholder props as Table, running against the same
TanStack getFilteredRowModel().
Show code
import type { ColumnDef } from '@elirobinson/react/components/organisms/VirtualTable';
import { VirtualTable } from '@elirobinson/react/components/organisms/VirtualTable';
type Order = {
id: string;
customer: string;
guide: string;
amount: string;
};
const CUSTOMERS = [
'Jordan Ellis',
'Priya Nair',
'Sam Okafor',
'Maria Gonzalez',
'Tomás Rivera',
'Aisha Bello',
'Liam Chen',
'Grace Kim',
'Noah Fischer',
'Ingrid Larsen',
];
const GUIDES = [
'Youth Football Fundamentals',
'Basketball Conditioning',
'Rugby Contact Basics',
'Athletics Sprint Mechanics',
'Netball Footwork Drills',
'Swim Stroke Technique',
];
function makeOrders(count: number): Order[] {
return Array.from({ length: count }, (_, index) => ({
id: `INV-${1000 + index}`,
customer: CUSTOMERS[index % CUSTOMERS.length],
guide: GUIDES[index % GUIDES.length],
amount: `$${(18 + (index % 5) * 6).toFixed(2)}`,
}));
}
const columns: ColumnDef<Order>[] = [
{ accessorKey: 'id', header: 'Invoice' },
{ accessorKey: 'customer', header: 'Customer' },
{ accessorKey: 'guide', header: 'Guide' },
{ accessorKey: 'amount', header: 'Amount' },
];
const orders = makeOrders(200);
export default function Filterable() {
return (
<VirtualTable
data={orders}
columns={columns}
height={360}
rowHeight={44}
filterable
filterPlaceholder="Filter orders"
/>
);
}Empty state
No orders yet
Show code
import type { ColumnDef } from '@elirobinson/react/components/organisms/VirtualTable';
import { VirtualTable } from '@elirobinson/react/components/organisms/VirtualTable';
type Order = {
id: string;
customer: string;
guide: string;
amount: string;
};
const columns: ColumnDef<Order>[] = [
{ accessorKey: 'id', header: 'Invoice' },
{ accessorKey: 'customer', header: 'Customer' },
{ accessorKey: 'guide', header: 'Guide' },
{ accessorKey: 'amount', header: 'Amount' },
];
export default function Empty() {
return <VirtualTable data={[]} columns={columns} emptyMessage="No orders yet" />;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
columnsrequired | ColumnDef<T>[] | — | — |
datarequired | T[] | — | — |
emptyMessage | string | — | Title shown by the EmptyState row when there are no rows to display. |
filterable | boolean | — | Shows a built-in global filter input above the table, wired to TanStack's `getFilteredRowModel()`. |
filterPlaceholder | string | — | Accessible label / placeholder for the built-in filter input. |
height | number | — | Fixed viewport height (px) for the windowed body. |
overscan | number | — | Rows to render outside the visible viewport, above and below. |
rowHeight | number | — | Estimated/actual row height (px) for the virtualizer. |
Accessibility
- Builds its own ARIA grid instead of a literal
<table>:role="table"on the root (witharia-colcount),role="row"on each row,role="columnheader"for headers,role="cell"for body cells. This is deliberate — the windowed rows render as absolutely positioned<div>s, and a<tbody>full of non-<tr>children is invalid HTML that browsers hoist out during parsing, so a real<table>would silently break. - Sortable headers are the same clickable
<button>asTable, and the header cell carriesaria-sortreflecting the live sort state. - The filter input is the same
type="search"witharia-labelset fromfilterPlaceholder. - Keyboard: header sort buttons and the filter input are real, focusable elements
reached by
Tabin document order, same asTable. Scrolling the windowed body is native scroll behavior — there's no roving grid navigation moving focus cell to cell;Tabplus scrolling is how you move through it. - The header row and the scrolling body share the same computed
grid-template-columns, so columns can't drift out of alignment between them.
Do
- Set an explicit height and a rowHeight close to the real rendered row height — a mismatch shows up as jumpy scrolling while rows settle into their measured size.
- Use filterable for a first-pass narrow, same as Table.
- Reach for it once row count runs into the thousands and pagination would mean dozens of pages.
- Keep cell content to a single line — cells clip with an ellipsis rather than wrap, since a fixed row height is what makes virtualization possible.
Don't
- Expect arrow-key cell navigation — there is none; this is a scrollable grid, not a spreadsheet widget.
- Switch between VirtualTable and Table for the same dataset depending on size — pick one per use case so the interaction pattern stays predictable.
- Add more columns than fit your narrowest supported width without checking — columns share the width equally with no built-in horizontal scroll.
- Query a literal table/tr/td element in tests or styles — there is not one; query by role instead.