Askr documentation
UI & Components

Virtual Table

Virtual Table: anatomy, keyboard behavior, state, and theming in Askr.

Example

import { VirtualTable, type VirtualTableColumn } from '@askrjs/themes/components';

const columns: readonly VirtualTableColumn<Project>[] = [
  { id: 'name', header: 'Name', cellComponent: ({ row }) => <span>{row.name}</span> },
  { id: 'status', header: 'Status', cellComponent: ({ row }) => <span>{row.status}</span> },
];

<VirtualTable
  rows={projects}
  rowHeight={48}
  headerHeight={44}
  getKey={(project) => project.id}
  columns={columns}
/>

Published props

Generated from the TypeScript declarations shipped by the installed package. Named types in the Type column define the accepted values.

VirtualTableAsChildProps

Import from @askrjs/ui/virtual-table.

PropTypeDefaultDescription
apiRefapiRef?: Ref<VirtualTableApi<Row> | null>;
asChildasChild: true;
childrenchildren: JSXElement;
columnscolumns: readonly VirtualTableColumn<Row>[];
defaultSelectedRowKeydefaultSelectedRowKey?: string | null | undefined;
getKeygetKey: (row: Row, index: number) => string | number;
headerHeightheaderHeight: number;
onRowClickonRowClick?: ((row: Row, rowIndex: number, rowKey: string, event: MouseEvent) => void) | undefined;
onScrollonScroll?: ((event: Event) => void) | undefined;
onSelectedRowKeyChangeonSelectedRowKeyChange?: ((next: string | null) => void) | undefined;
overscanoverscan?: VirtualOverscan | undefined;
refref?: Ref<HTMLElement>;
rowHeightrowHeight: number;
rowsrows: readonly Row[];
selectedRowKeyselectedRowKey?: string | null | undefined;
tableWidthtableWidth?: "compact" | undefined;
viewportviewport?: "lg" | undefined;

VirtualTableCellComponentProps

Import from @askrjs/ui/virtual-table.

PropTypeDefaultDescription
columncolumn: VirtualTableColumn<Row>;
rowrow: Row;
rowIndexrowIndex: number;
rowKeyrowKey: string;
selectedselected: boolean;

VirtualTableProps

Import from @askrjs/ui/virtual-table.

PropTypeDefaultDescription
apiRefapiRef?: Ref<VirtualTableApi<Row> | null>;
asChildasChild?: false | undefined;
columnscolumns: readonly VirtualTableColumn<Row>[];
defaultSelectedRowKeydefaultSelectedRowKey?: string | null | undefined;
getKeygetKey: (row: Row, index: number) => string | number;
headerHeightheaderHeight: number;
onRowClickonRowClick?: ((row: Row, rowIndex: number, rowKey: string, event: MouseEvent) => void) | undefined;
onScrollonScroll?: ((event: Event) => void) | undefined;
onSelectedRowKeyChangeonSelectedRowKeyChange?: ((next: string | null) => void) | undefined;
overscanoverscan?: VirtualOverscan | undefined;
refref?: Ref<HTMLElement>;
rowHeightrowHeight: number;
rowsrows: readonly Row[];
selectedRowKeyselectedRowKey?: string | null | undefined;
tableWidthtableWidth?: "compact" | undefined;
viewportviewport?: "lg" | undefined;

Purpose

Virtual Table applies the same windowing as Virtual List, but along columns as well as rows. Each entry in `columns` is a `VirtualTableColumn` — an `id`, a `header` (string or element), an optional `width`, and a `cellComponent` that renders one cell for one row — so the table describes itself as data rather than as nested markup. Use it when both the row count and the column count are large enough that a plain Table would put more nodes in the DOM than the browser can scroll smoothly.

Install and import

VirtualTable is imported from @askrjs/ui/virtual-table; @askrjs/themes has no dedicated `/virtual-table` subpath or wrapped/restyled version of it, but — like VirtualList — the same component is re-exported through the `@askrjs/themes/components` barrel, so importing it from either package works. What does carry over regardless of which import you use is styling — the default theme's CSS targets [data-slot="virtual-table"] along with its header, row, and cell slots, so loading @askrjs/themes/default is enough to style it.

Live examples

VirtualTable takes rows, rowHeight, headerHeight, getKey, and columns — an array of {id, header, cellComponent, width?} — and renders a sticky header above windowed, fixed-height rows. It fits a log viewer or an admin table with thousands of records, where you need real columns rather than one line per item and can't pay the DOM cost of rendering every row up front.

Anatomy

The rendered structure is a scrollable root, a sticky header row built from your columns' header values, and a virtualized body where only rows in the visible range (plus overscan) actually mount, padded by spacer rows above and below to keep scrollbar height accurate. Each cell comes from calling column.cellComponent with {row, rowIndex, rowKey, column, selected} for that row/column pair.

State model

VirtualTable tracks the same scroll state as Virtual List (scrollTop, visibleRange, isAtTop/isAtBottom) plus row selection: selectedRowKey and selectedRowIndex. Selection can be controlled through selectedRowKey/onSelectedRowKeyChange or left uncontrolled with defaultSelectedRowKey; either way, clicking a row — or calling selectRowByKey/selectRowByIndex through apiRef — updates which row's cells render with selected: true.

Keyboard and accessibility

VirtualTable doesn't implement its own keyboard navigation between cells or rows — onRowClick and the imperative selection methods are pointer and programmatic entry points, and any keyboard behavior inside a cell comes from what cellComponent renders. Because rows outside the visible range are unmounted rather than hidden, the same caveat as Virtual List applies: assistive technology and browser find-in-page only see currently-rendered rows, not the full dataset.

Styling and tokens

@askrjs/themes/default styles the [data-slot="virtual-table"] root as a bordered, rounded surface with a fixed viewport height (28rem at the 'lg' viewport size), plus an --ak-virtual-table-min-inline-size custom property controlling the minimum table width before horizontal scrolling kicks in. The tableWidth prop accepts 'compact' to switch to a denser column layout defined by that same CSS — again, all CSS-driven against markup VirtualTable already emits, with no separate themed component providing it.

API

VirtualTableProps<Row> requires rows, rowHeight, headerHeight, getKey, and columns; optional props include overscan, selectedRowKey, defaultSelectedRowKey, onSelectedRowKeyChange, onRowClick, onScroll, viewport, tableWidth ('compact'), apiRef, and asChild. VirtualTableApi adds selection methods on top of the Virtual List-style API: getSelectedRowKey(), getSelectedRowIndex(), selectRowByKey(rowKey), selectRowByIndex(index), and clearSelection(), alongside scrollToIndex/scrollToTop/scrollToBottom/getState.

Edge cases

Like Virtual List, row height (rowHeight) and header height (headerHeight) are both fixed — variable-height content will clip or misalign the virtualization math. Column width is optional per column (width?: number | string); omit it on every column and layout falls back to normal table sizing, which can fight with fixed-rowHeight virtualization if content forces wrapping. Unstable or colliding getKey values cause the same selection- and scroll-jumping issues Virtual List has, so keep rowKey stable across data refetches.

See Virtual List for the single-column version of this same windowing approach. See Table for a non-virtualized table when your row count is small enough that windowing isn't worth the complexity. See Data Table for a styled bordered container — unrelated to VirtualTable's own built-in bordered-surface styling, but visually similar.