VirtualTable
Render a large semantic table while mounting only nearby rows, with predictable fixed row heights.
VirtualTableInteractive playground
Explore up to 50,000 release records with search, status filtering, sorting and position jumps. Change the viewport, row height and overscan, compare auto and fixed layout, and toggle the registered table sections. The mounted count includes data rows only.
<RegorApp id="virtual-table-demo" src="./playground.ts"/> import {
defineBadgeComponents,
defineButtonComponents,
defineFlexComponents,
defineFormComponents,
defineFormInputField,
defineFormSelectField,
defineGridComponents,
defineIconComponents,
definePanelComponents,
defineVirtualTableComponents,
type FormSelectOption,
} from '@purestack/ts-components'
import type { SemanticTone } from '@purestack/ts-style'
import { lucide_chevron_down } from '@purestack/ts-svg-icons'
import {
batch,
type ComputedRef,
computed,
createApp,
defineComponent,
html,
observe,
onMounted,
onUnmounted,
type Ref,
type RefOrValue,
ref,
type SRef,
sref,
} from 'regor'
export interface ReleaseRecord {
id: number
title: string
state: string
tone: SemanticTone
}
export interface ReleaseTableRow {
item: RefOrValue<ReleaseRecord>
index: RefOrValue<number>
}
export interface ReleaseTableHeader {}
export interface ReleaseTableFooter {}
export interface ReleaseTableColumns {}
const releaseTableRowTemplate = html`<tr :data-row="index">
<td class="px-3 py-0 bb-1 b-subtle ws-nowrap">{{ item.id }}</td>
<td
class="px-3 py-0 bb-1 b-subtle ws-nowrap overflow-hidden text-ellipsis"
:title="item.title"
>
{{ item.title }}
</td>
<td class="px-3 py-0 bb-1 b-subtle ws-nowrap">
<Badge :tone="item.tone" variant="surface"
>{{ item.state }}</Badge
>
</td>
</tr>`
const releaseTableHeaderTemplate = html`<thead>
<tr>
<th scope="col" class="px-3 py-2 tone-fill-surface">ID</th>
<th scope="col" class="px-3 py-2 tone-fill-surface">Release</th>
<th scope="col" class="px-3 py-2 tone-fill-surface">Status</th>
</tr>
</thead>`
const releaseTableFooterTemplate = html`<tfoot>
<tr>
<td
colspan="3"
class="px-3 py-2 tone-fill-surface ws-nowrap overflow-hidden text-ellipsis"
>
Release queue · illustrative data
</td>
</tr>
</tfoot>`
const releaseTableColumnsTemplate = html`<colgroup>
<col style="width: 15%"/>
<col style="width: 55%"/>
<col style="width: 30%"/>
</colgroup>`
const releaseTableRow = defineComponent<ReleaseTableRow>(
releaseTableRowTemplate,
{ props: ['item', 'index'] },
)
const releaseTableHeader = defineComponent<ReleaseTableHeader>(
releaseTableHeaderTemplate,
)
const releaseTableFooter = defineComponent<ReleaseTableFooter>(
releaseTableFooterTemplate,
)
const releaseTableColumns = defineComponent<ReleaseTableColumns>(
releaseTableColumnsTemplate,
)
export interface VirtualTablePlayground {
records: SRef<ReleaseRecord[]>
filtered: ComputedRef<ReleaseRecord[]>
query: Ref<string>
count: Ref<string>
state: Ref<string>
sort: Ref<string>
height: Ref<string>
rowHeight: Ref<string>
overscan: Ref<string>
layout: Ref<'auto' | 'fixed'>
header: Ref<boolean>
footer: Ref<boolean>
columns: Ref<boolean>
target: Ref<number | string>
mountedRows: Ref<number>
rowRange: Ref<string>
scrollOffset: Ref<number>
counts: FormSelectOption[]
states: FormSelectOption[]
sorts: FormSelectOption[]
heights: FormSelectOption[]
densities: FormSelectOption[]
buffers: FormSelectOption[]
layouts: FormSelectOption[]
load: () => void
clear: () => void
reset: () => void
first: () => void
last: () => void
jump: () => void
}
const virtualTablePlaygroundTemplate = html`<Flex direction="column">
<Grid columns="1" columnsMd="2">
<FormSelectField
id="table-count"
label="Dataset size"
:model="count"
:options="counts"
@change="load"/>
<FormInputField
id="table-search"
label="Find a release"
placeholder="Try Release 42"
:model="query"/>
<FormSelectField
id="table-state"
label="Status filter"
:model="state"
:options="states"/>
<FormSelectField
id="table-sort"
label="Sort by ID"
:model="sort"
:options="sorts"/>
</Grid>
<Panel variant="surfaceAlt" bodyClass="p-3 min-w-0">
<Flex justify="between" align="center" wrap="true">
<p class="text-eyebrow m-0">LIVE PREVIEW · RELEASE TABLE</p>
<Badge tone="accent" variant="surface"
>{{ filtered.length }} matching records</Badge
>
</Flex>
<Grid columns="1" columnsSm="3" class="my-3">
<div>
<p class="m-0 text-muted">Data rows mounted</p>
<strong id="table-mounted">{{ mountedRows }}</strong>
</div>
<div>
<p class="m-0 text-muted">Positions</p>
<strong>{{ rowRange }}</strong>
</div>
<div>
<p class="m-0 text-muted">Scroll offset</p>
<strong>{{ scrollOffset }} px</strong>
</div>
</Grid>
<VirtualTable
id="release-table-viewport"
:items="filtered"
:height="height"
:itemHeight="rowHeight"
:overscan="overscan"
rowComponent="ReleaseTableRow"
:headerComponent="header ? 'ReleaseTableHeader' : ''"
:footerComponent="footer ? 'ReleaseTableFooter' : ''"
:colGroupComponent="columns ? 'ReleaseTableColumns' : ''"
:tableLayout="layout"
tabindex="0"
role="region"
aria-label="Scrollable release table"
class="b-1 b-subtle rounded-md"/>
<FormStatus r-if="!filtered.length" role="status"
>{{ records.length ? 'No releases match. Clear the search or change the status.' : 'The table is empty. Reload a dataset to continue.' }}</FormStatus
>
<p class="mb-0 text-muted">
The header and footer stay visible while data rows scroll. Focus the
region for keyboard scrolling; wide content scrolls horizontally inside
it.
</p>
</Panel>
<Grid columns="1" columnsMd="2">
<FormSelectField
id="table-height"
label="Viewport height"
:model="height"
:options="heights"/>
<FormSelectField
id="table-row-height"
label="Row height"
:model="rowHeight"
:options="densities"/>
<FormSelectField
id="table-overscan"
label="Overscan"
:model="overscan"
:options="buffers"/>
<FormSelectField
id="table-layout"
label="Table layout"
:model="layout"
:options="layouts"/>
</Grid>
<Flex wrap="true">
<FormCheck id="table-header" label="Header" :checked="header"/>
<FormCheck id="table-footer" label="Footer" :checked="footer"/>
<FormCheck id="table-columns" label="Column widths" :checked="columns"/>
</Flex>
<p class="m-0 text-muted">
Fixed layout uses the viewport width and the column group. Auto layout
follows content width. Cell content stays on one line so each data row fits
its configured height.
</p>
<Grid columns="1" columnsMd="2" alignItems="end">
<FormInputField
id="table-target"
label="Jump to matching position (1-based)"
type="number"
min="1"
:model="target"/>
<Flex wrap="true"
><Btn tone="accent" :disabled="!filtered.length" @click="jump"
>Jump to row</Btn
><Btn variant="outline" :disabled="!filtered.length" @click="first"
>First</Btn
><Btn variant="outline" :disabled="!filtered.length" @click="last"
>Last</Btn
></Flex
>
</Grid>
<Flex wrap="true"
><Btn variant="outline" @click="load">Reload dataset</Btn
><Btn variant="outline" @click="clear">Empty table</Btn
><Btn tone="accent" variant="surface" @click="reset"
>Reset playground</Btn
></Flex
>
</Flex>`
function createReleases(count: number): ReleaseRecord[] {
const states = ['Ready', 'Review', 'Queued']
const tones: SemanticTone[] = ['success', 'warning', 'info']
return Array.from({ length: count }, (_, index) => ({
id: index + 1,
title: `Release ${index + 1} · ${index % 2 ? 'Component documentation' : 'Framework improvements'}`,
state: states[index % 3],
tone: tones[index % 3],
}))
}
function createVirtualTablePlayground(): VirtualTablePlayground {
const records = sref(createReleases(10000))
const query = ref(''),
count = ref('10000'),
state = ref('all'),
sort = ref('asc')
const height = ref('360'),
rowHeight = ref('48'),
overscan = ref('4'),
layout = ref<'auto' | 'fixed'>('fixed')
const header = ref(true),
footer = ref(true),
columns = ref(true),
target = ref<number | string>(5000)
const mountedRows = ref(0),
rowRange = ref('-'),
scrollOffset = ref(0)
const filtered = computed(() => {
const result = records().filter(
(item) =>
(state() === 'all' || state() === item.state) &&
item.title.toLowerCase().includes(query().trim().toLowerCase()),
)
return sort() === 'desc' ? result.reverse() : result
})
const viewport = () =>
document.querySelector<HTMLElement>('#release-table-viewport')
const scrollToPosition = (position: number) => {
const element = viewport()
if (!element) return
const index = Math.max(0, Math.min(filtered().length - 1, position - 1))
element.scrollTop = index * Number(rowHeight())
element.dispatchEvent(new Event('scroll'))
}
const stop = observe(filtered, () => scrollToPosition(1))
let observer: MutationObserver | undefined
let element: HTMLElement | null = null
const measure = () => {
const rows = Array.from(
element?.querySelectorAll<HTMLElement>('[data-row]') ?? [],
)
mountedRows(rows.length)
rowRange(
rows.length
? `${Number(rows[0].dataset.row) + 1}–${Number(rows.at(-1)?.dataset.row) + 1}`
: '-',
)
scrollOffset(Math.round(element?.scrollTop ?? 0))
}
onMounted(() => {
element = viewport()
if (!element) return
observer = new MutationObserver(measure)
observer.observe(element, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'data-row'],
})
element.addEventListener('scroll', measure)
measure()
})
onUnmounted(() => {
stop()
observer?.disconnect()
element?.removeEventListener('scroll', measure)
})
return {
records,
filtered,
query,
count,
state,
sort,
height,
rowHeight,
overscan,
layout,
header,
footer,
columns,
target,
mountedRows,
rowRange,
scrollOffset,
counts: [100, 1000, 10000, 50000].map((n) => ({
label: `${n.toLocaleString('en-US')} records`,
value: String(n),
})),
states: [
{ label: 'All statuses', value: 'all' },
...['Ready', 'Review', 'Queued'].map((value) => ({
label: value,
value,
})),
],
sorts: [
{ label: 'Oldest first', value: 'asc' },
{ label: 'Newest first', value: 'desc' },
],
heights: [240, 360, 480].map((n) => ({
label: `${n} px`,
value: String(n),
})),
densities: [48, 60, 72].map((n) => ({
label: `${n} px`,
value: String(n),
})),
buffers: [1, 4, 12].map((n) => ({ label: `${n} rows`, value: String(n) })),
layouts: [
{ label: 'Fixed · fit the viewport', value: 'fixed' },
{ label: 'Auto · fit the content', value: 'auto' },
],
load: () => records(createReleases(Number(count()))),
clear: () => records([]),
first: () => scrollToPosition(1),
last: () => scrollToPosition(filtered().length),
jump: () =>
scrollToPosition(
Number.isFinite(Number(target())) ? Math.trunc(Number(target())) : 1,
),
reset: () =>
batch(() => {
query('')
count('10000')
state('all')
sort('asc')
height('360')
rowHeight('48')
overscan('4')
layout('fixed')
header(true)
footer(true)
columns(true)
target(5000)
records(createReleases(10000))
}),
}
}
const virtualTablePlayground = defineComponent<VirtualTablePlayground>(
virtualTablePlaygroundTemplate,
{ context: createVirtualTablePlayground },
)
const icons: Record<string, string> = {
'lucide:chevron-down': lucide_chevron_down,
}
createApp(
{
components: {
VirtualTablePlayground: virtualTablePlayground,
ReleaseTableRow: releaseTableRow,
ReleaseTableHeader: releaseTableHeader,
ReleaseTableFooter: releaseTableFooter,
ReleaseTableColumns: releaseTableColumns,
...defineBadgeComponents(),
...defineButtonComponents(),
...defineFlexComponents(),
...defineFormComponents(),
...defineFormInputField(),
...defineFormSelectField(),
...defineGridComponents(),
...defineIconComponents((name) => icons[name] ?? ''),
...definePanelComponents(),
...defineVirtualTableComponents(),
},
},
{
selector: 'app#virtual-table-demo',
template: html`<VirtualTablePlayground/>`,
},
) A complete table composition
A small, copyable implementation with a typed row, semantic header, column group and sticky footer. The table can scroll horizontally on narrow screens while every data row stays 48 pixels tall.
<RegorApp id="virtual-table-basic-demo" src="./basic-table.ts"/> import { defineVirtualTableComponents } from '@purestack/ts-components'
import { createApp, defineComponent, html, type RefOrValue } from 'regor'
export interface Build {
id: number
name: string
duration: number
}
export interface BuildRow {
item: RefOrValue<Build>
index: RefOrValue<number>
}
export interface BuildHeader {}
export interface BuildColumns {}
export interface BuildFooter {}
const buildRowTemplate = html`<tr :aria-rowindex="index + 2">
<td class="px-3 py-0 bb-1 b-subtle ws-nowrap">{{ item.id }}</td>
<td class="px-3 py-0 bb-1 b-subtle ws-nowrap">{{ item.name }}</td>
<td class="px-3 py-0 bb-1 b-subtle ws-nowrap">{{ item.duration }} ms</td>
</tr>`
const buildHeaderTemplate = html`<thead><tr>
<th scope="col" class="px-3 py-2 tone-fill-surface">ID</th>
<th scope="col" class="px-3 py-2 tone-fill-surface">Build</th>
<th scope="col" class="px-3 py-2 tone-fill-surface">Duration</th>
</tr></thead>`
const buildColumnsTemplate = html`<colgroup><col style="width: 5rem"/><col style="width: 14rem"/><col style="width: 8rem"/></colgroup>`
const buildFooterTemplate = html`<tfoot><tr><td colspan="3" class="px-3 py-2 tone-fill-surface">500 illustrative build results</td></tr></tfoot>`
const buildRow = defineComponent<BuildRow>(buildRowTemplate, {
props: ['item', 'index'],
})
const buildHeader = defineComponent<BuildHeader>(buildHeaderTemplate)
const buildColumns = defineComponent<BuildColumns>(buildColumnsTemplate)
const buildFooter = defineComponent<BuildFooter>(buildFooterTemplate)
export interface BuildTableExample {
builds: Build[]
}
const buildTableTemplate = html`<VirtualTable
:items="builds" height="300" itemHeight="48" overscan="4"
rowComponent="BuildRow" headerComponent="BuildHeader"
colGroupComponent="BuildColumns" footerComponent="BuildFooter"
tableLayout="auto" tabindex="0" role="region" aria-label="Build results table"
class="b-1 b-subtle rounded-md"
/>`
const buildTable = defineComponent<BuildTableExample>(buildTableTemplate, {
context: () => ({
builds: Array.from({ length: 500 }, (_, index) => ({
id: index + 1,
name: `Build ${index + 1}`,
duration: 120 + (index % 80),
})),
}),
})
createApp(
{
components: {
BuildTableExample: buildTable,
BuildRow: buildRow,
BuildHeader: buildHeader,
BuildColumns: buildColumns,
BuildFooter: buildFooter,
...defineVirtualTableComponents(),
},
},
{
selector: 'app#virtual-table-basic-demo',
template: html`<BuildTableExample />`,
},
) How the table window works
The component renders a real table. Spacer rows before and after the mounted data rows preserve the scroll distance; the header, footer and column group remain outside the virtualized data window.
DATA
10,000 records
The whole source array remains in memory. Filtering and sorting are application operations performed before passing items.
WINDOW
16 data rows
At a 360-pixel viewport, 48-pixel rows and overscan 4, the window budget is ceil(360 / 48) + 8. Fewer rows remain near the end.
GEOMETRY
48 pixels each
Data-row offsets come from itemHeight. Header and footer sections also occupy space in the native table.
The start index is max(0, floor(scrollTop / itemHeight) - overscan). The budget is ceil(height / itemHeight) + 2 × overscan, limited by the remaining items. It uses the viewport height, not the area left after sticky sections, so mounted and actually visible counts differ.
Keeping fixed rows fixed
HTML table row height is a minimum: content can force a row to grow. Keep cell content to a bounded line and fit padding, borders, badges and controls inside itemHeight. The playground uses zero vertical cell padding and single-line content. Fixed-layout release titles truncate and expose their full text through a title attribute.
Wrapping descriptions or expandable details need VariableVirtualTable. Increasing overscan cannot repair incorrect fixed-height geometry.
Filtering, sorting and navigation
The example derives items with a computed filter and sort, then resets scrollTop when that sequence changes. Position means index in the current matching array; item.id remains the stable record identity. Sorting does not mutate the source array.
The jump control sets the viewport scrollTop to (position - 1) × itemHeight and dispatches a scroll event for immediate synchronization. The browser clamps the final offset. Header and footer geometry affect exact visual alignment; this is application code, not a public scrollToIndex method.
Composing a real table
| Component prop | Root element | Purpose |
|---|---|---|
| rowComponent | tr | Receives item and index; render td or th cells. |
| colGroupComponent | colgroup | Contains col elements that guide column widths. |
| headerComponent | thead | Contains header rows and th cells with scope="col". |
| footerComponent | tfoot | Contains summary rows; colspan must match your columns. |
Each part is explicitly defined and registered in the example file. Header, footer and column-group components receive no item or index props. For a dynamic summary, supply application context to your own footer; the virtual table does not calculate aggregates.
The existing theme makes the header sticky at the top and footer cells sticky at the bottom. Give those cells a surface background so scrolling content does not show through. Only data rows are virtualized; keep the surrounding sections compact.
Auto layout sizes the table from content and uses a minimum width of 100%; widths may shift as different rows mount. Fixed layout uses width: 100% with table-layout: fixed. A column group makes the intended widths explicit. Toggle both modes in the playground; oversized cell content can still require horizontal scrolling.
Behavior and accessibility
- Keyboard scrolling: the examples add tabindex="0" to the viewport. Arrow keys and Page Up / Page Down use native browser scrolling. These components do not implement spreadsheet navigation or roving focus.
- Table semantics: render valid table sections and scoped column headers. The examples name the scrollable region; attributes on the component root target that outer div, not the inner table. This is a native table, not an ARIA grid.
- State and focus: keep durable state on records or in an external store, keyed by stable identity. A row outside the mounted window is removed, including any focused control inside it.
- Empty results: compose a message outside the virtual content. Header and footer sections can remain when the items array is empty.
- Complete data access: browser find, printing and assistive navigation only see mounted rows. Provide application search or a full-data view or export where complete access is needed.
- Memory and loading: virtualization reduces mounted DOM, not the source array or filtering cost. Data fetching, pagination, selection and sorting remain application responsibilities.
API reference
VirtualTable contract
Props below are the public template API. RefOrValue accepts a literal or a reactive ref; bind refs with a colon-prefixed attribute. Native attributes and events can be passed through the component root.
Data
items
RefOrValue<unknown[]> - Default
[]
The complete source array. Only a window is mounted; all records remain in memory. Bind a ref or computed array to replace, filter or sort it. Non-array values resolve to an empty array.
Viewport and window
height
RefOrValue<number | string> - Default
560
Viewport height in pixels. Positive numbers and numeric strings are accepted; invalid, non-finite and non-positive values fall back to 560. Use pixel values, not CSS units such as rem or %. This controls the viewport, not a row.
itemHeight
RefOrValue<number | string> - Default
44
Height used for every data-row offset and spacer calculation, in pixels. Defaults to 44 when invalid or non-positive. Native table content can force rows taller: keep cell content, padding and borders within this height. It does not measure rows.
overscan
RefOrValue<number | string> - Default
6
Buffer subtracted from the visible start and added twice to the total window budget. Larger values mount more data rows. Use a positive integer; positive fractions truncate, while zero, negative and invalid values fall back to 6.
Table component registration
rowComponent
RefOrValue<string> - Default
not set
Registered component name for each data row. It receives item and the absolute zero-based index in the current items array. Its root must be tr with valid cells. No automatic item formatting is provided.
colGroupComponent
RefOrValue<string> - Default
not set
Optional registered component with a colgroup root and col children. It receives no row props. Use it to describe column widths, together with an appropriate content layout.
headerComponent
RefOrValue<string> - Default
not set
Optional registered component with a thead root, tr rows and scoped th cells. It receives no row props. Theme styles make the header sticky; give header cells an opaque surface background.
Column layout
tableLayout
RefOrValue<'auto' | 'fixed'> - Default
auto
auto creates a content-sized table with min-width: 100%; fixed uses a 100%-wide table and fixed column layout. Use a column group for explicit widths. This changes column sizing, not virtual row-height calculation.
Row contract
import type { RefOrValue } from 'regor'
interface ExampleRow {
item: RefOrValue<YourRecord>
index: RefOrValue<number>
} Replace YourRecord with your application type and declare item and index in the component's props list. Index is a position in the current array, not a stable identity. The examples define exported interfaces, named templates and explicit registrations in their own TypeScript files.
Composition and public API
No slots. Register defineVirtualTableComponents and your named table sections. Controls, empty messages and durable state belong to your application. Native attributes and events are inherited by the scrollable root div.
Internal computed fields and scrollTop in the exported interface are implementation details, not additional public props. The template API is the nine registered inputs above.