Skip to content

Virtual Table

VirtualTable is a compound component. Instead of passing a monolithic config object, you compose the table from sub-components:

<VirtualTable totalCount={100} rowHeight={40}>
<VirtualTable.Header>
<VirtualTable.Column>Name</VirtualTable.Column>
<VirtualTable.Column>Email</VirtualTable.Column>
</VirtualTable.Header>
<VirtualTable.Body>
{(index) => { /* ... */ }}
</VirtualTable.Body>
</VirtualTable>

Each sub-component — Header, Column, Body, Row, Cell, SkeletonRow, Empty, Footer — is attached to the VirtualTable namespace.

The sub-components are declarative — you use them to describe the table’s structure, and VirtualTable takes care of rendering the actual DOM.

This design means:

  • No wrapper divs from sub-components polluting the DOM tree
  • Full control over the rendered output stays with VirtualTable
  • ClassNames merge via tailwind-merge — defaults from factory functions are combined with per-instance overrides

Column widths are defined on each VirtualTable.Column. All rows — header, body, and skeleton — share the same column widths, keeping everything perfectly aligned.

Width valueUse case
'1fr'Proportional, flexible
'2fr'Double-width proportional
200Fixed pixel width
(omitted)Default — equal distribution

When minWidth is set on a column, it prevents the column from collapsing below that size.

VirtualTable only renders the rows visible in the viewport, plus a configurable overscan buffer (default: 5 rows above and below). This keeps DOM node count constant regardless of dataset size.

  1. Scroll handler — On every scroll event, VirtualTable calculates the visible row range from scrollTop, clientHeight, and rowHeight.
  2. Synchronous update — The new range is applied synchronously to prevent visual tearing during fast scrolling.
  3. Absolute positioning — Each row is positioned using translateY(index × rowHeight). A container element is sized to totalCount × rowHeight to give the scrollbar correct proportions.
  4. CSS containment — Rows use contain: layout style paint and will-change: transform for optimal compositing.

Every time the visible range changes, VirtualTable calls onRangeChange with { start, end }. This is the hook point for triggering page fetches — typically you pass cache.handleRangeChange from useTableCache:

<VirtualTable
totalCount={cache.totalCount}
rowHeight={40}
onRangeChange={cache.handleRangeChange}
>

Columns with resizable={true} render a drag handle on their right edge. Dragging the handle resizes the column in real time.

  • Min/max constraintsminWidth (default: 50px) and maxWidth clamp the drag range.
  • Container-aware — The column cannot be resized wider than the container minus the minimum widths of all other columns.
  • Cursor override — During a drag, document.body.style.cursor is set to col-resize and restored on mouse up.
  • fr value calculation — On drag end, the component calculates the equivalent fr value of the new pixel width relative to other fr columns. This is reported via the onResizeEnd callback:
onResizeEnd?: (width: number, startWidth: number, frValue: number) => void
ParameterDescription
widthFinal pixel width after drag
startWidthPixel width when drag started
frValueEquivalent fr value relative to other fr columns

The useTableColumnWidths hook consumes these callbacks to persist widths automatically.

Factory functions let you bake in default props for sub-components. This is useful when you have a design system and want consistent styling without repeating className on every instance.

import {
createTableHeader,
createTableColumn,
createTableBody,
createTableRow,
} from '@requence/table'
const Header = createTableHeader({
className: 'bg-zinc-900 text-xs uppercase text-zinc-500',
})
const Column = createTableColumn({
className: 'px-4 py-2',
resizable: true,
minWidth: 80,
})
const Body = createTableBody()
const Row = createTableRow({
className: 'border-b border-zinc-800 hover:bg-zinc-900/50',
})

Then use them in place of the VirtualTable.* sub-components:

<VirtualTable totalCount={100} rowHeight={40}>
<Header>
<Column width="2fr">Name</Column>
<Column width="1fr">Role</Column>
</Header>
<Body>
{(index) => (
<Row>
<VirtualTable.Cell className="px-4">{name}</VirtualTable.Cell>
<VirtualTable.Cell className="px-4">{role}</VirtualTable.Cell>
</Row>
)}
</Body>
</VirtualTable>

How merging works: Factory-created components have built-in default props that are merged with instance props. ClassNames are combined via tailwind-merge, so instance classes override conflicting defaults.

Available factory functions:

FunctionCreates
createTableHeaderVirtualTable.Header with defaults
createTableColumnVirtualTable.Column with defaults
createTableBodyVirtualTable.Body with defaults
createTableRowVirtualTable.Row with defaults
createTableSkeletonRowVirtualTable.SkeletonRow with defaults
createTableEmptyVirtualTable.Empty with defaults
createTableFooterVirtualTable.Footer with defaults

When totalCount is 0 and an Empty sub-component is provided, VirtualTable renders the header followed by the empty state content instead of the scroll container:

<VirtualTable.Empty className="py-20 text-zinc-500">
No results found.
</VirtualTable.Empty>

The empty state is centered within a flex container. The header still renders above it so users can see column headings.

When the Body render function returns null for a given index (typically because the data hasn’t been fetched yet), VirtualTable renders the SkeletonRow sub-component in its place:

<VirtualTable.SkeletonRow className="animate-pulse">
<VirtualTable.Cell className="px-4">
<div className="h-3 w-24 rounded bg-zinc-800" />
</VirtualTable.Cell>
<VirtualTable.Cell className="px-4">
<div className="h-3 w-32 rounded bg-zinc-800" />
</VirtualTable.Cell>
</VirtualTable.SkeletonRow>

Skeleton rows receive the same grid template as data rows, so the placeholder elements align with the header columns. They inherit the same positioning and height as regular data rows.