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.

organismsSource
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

PropTypeDefaultDescription
columnsrequiredColumnDef<T>[]
datarequiredT[]
emptyMessagestringTitle shown by the EmptyState row when there are no rows to display.
filterablebooleanShows a built-in global filter input above the table, wired to TanStack's `getFilteredRowModel()`.
filterPlaceholderstringAccessible label / placeholder for the built-in filter input.
heightnumberFixed viewport height (px) for the windowed body.
overscannumberRows to render outside the visible viewport, above and below.
rowHeightnumberEstimated/actual row height (px) for the virtualizer.

Accessibility

  • Builds its own ARIA grid instead of a literal <table>: role="table" on the root (with aria-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> as Table, and the header cell carries aria-sort reflecting the live sort state.
  • The filter input is the same type="search" with aria-label set from filterPlaceholder.
  • Keyboard: header sort buttons and the filter input are real, focusable elements reached by Tab in document order, same as Table. Scrolling the windowed body is native scroll behavior — there's no roving grid navigation moving focus cell to cell; Tab plus 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.