VirtualTable

Render a large semantic table while mounting only nearby rows, with predictable fixed row heights.

VirtualTable

View source · API reference

Interactive 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.

footerComponent

RefOrValue<string>
Default
not set

Optional registered component with a tfoot root and valid rows and cells. Footer cells are sticky at the bottom under the theme styles. Supply your own content or application context; no automatic aggregates or row props are passed.

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.