Flow Table

Virtualized rows in a real flow table
A real <table> virtualized in flow: rows carry 1-3 text lines each and are measured, so every row has its natural height while spacer rows keep the virtual offsets exact. Column widths can be left to the browser (auto), pinned from the first window, or pinned from an explicit colgroup; when the table is wider than the container it scrolls horizontally with its own scrollbar.

How to build a feature like this

VirtualScrollTable already renders real table markup (<table> / <thead> / <tbody> / <tfoot>) for accessible tabular data. The flow-table flag goes one step further: instead of absolutely positioning each row at a computed pixel offset, mounted rows stay in real table flow between two invisible spacer rows (the leading one stands in for the skipped rows above, the trailing one keeps the scroll height). The browser's own table layout then sizes and column-aligns the rows - including across the header - so row heights are their natural content heights (measured) and the result reads as a genuine table to screen readers and CSS table styling alike.

1. Size the scroll viewport

The component renders a scrollable host around the inner table. Give the host a definite height (an explicit height, or a flex/grid slot with min-height: 0); the vertical axis is virtualized, so the table can hold hundreds of thousands of rows while only the window is in the DOM. Width is automatic: the table is as wide as its content, and when that exceeds the host width the host scrolls horizontally with its own native scrollbar while the vertical range keeps working.

2. Model the rows and choose a height strategy

Flow mode is orthogonal to how row heights are known: pass a numeric item-size for uniform rows (arithmetic positioning), or leave it unset so each row is measured and keeps its natural height - the right choice when rows carry one to several lines of text, badges, or anything whose height only the browser knows. Rows re-measure automatically when their content changes, and the spacer rows absorb the difference so later rows never drift.

3. Write semantic slots and turn the flag on

Three slots make the table. The #header slot emits a <tr> of <th> cells (it lands in the <thead>), the optional #footer slot emits a <tr> of <td>s (the <tfoot>), and the #item slot is called once per mounted row - its content must be bare <td> cells, because they become the direct children of the engine's <tr>. Every row must emit the same number of cells or the browser cannot align the columns.

<script setup lang="ts">
import { VirtualScrollTable } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';

// Real row objects; every row renders the same number of &lt;td> cells.
interface Member { id: number; name: string; email: string; role: string; meta: string[]; }
const members: Member[] = Array.from({ length: 20_000 }, (_, id) => ({
  id,
  name: `Member ${ id }`,
  email: `member${ id }@example.com`,
  role: [ 'Admin', 'Editor', 'Viewer' ][ id % 3 ]!,
  meta: Array.from({ length: id % 3 }, (_, line) => `detail ${ id }.${ line }`), // extra name lines
}));
</script>

<template>
  <VirtualScrollTable
    class="members"
    :items="members"
    flow-table
    aria-label="Members table"
  >
    <template #header>
      <tr>
        <th>#</th>
        <th>Name</th>
        <th>Email</th>
        <th>Role</th>
      </tr>
    </template>

    &lt;!-- The item slot emits bare <td> cells: they become children of the
         engine's <tr>, so every row must emit the same column count. -->
    <template #item="{ item }">
      <td class="num">{{ item.id }}</td>
      <td>
        <div class="name">{{ item.name }}</div>
        <div v-for="(line, i) in item.meta" :key="i" class="meta">{{ line }}</div>
      </td>
      <td class="email">{{ item.email }}</td>
      <td><span class="badge">{{ item.role }}</span></td>
    </template>
  </VirtualScrollTable>
</template>

<style scoped>
.members {
  height: 480px;
  border: 1px solid oklch(50% 0 0 / 0.2);
} /* scroll viewport */
/* Real table cells, laid out and column-aligned by the browser. */
.members :deep(td), .members :deep(th) {
  padding: 8px 12px;
  text-align: start;
  border-bottom: 1px solid oklch(50% 0 0 / 0.08);
}
.name {
  font-weight: 600;
}
.meta {
  font-size: 11px;
  opacity: 0.6;
}
.num {
  font-variant-numeric: tabular-nums;
  opacity: 0.5;
}
.email {
  font-size: 12px;
  opacity: 0.8;
}
.badge {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 999px;
  font-size: 11px;
  background: oklch(90% 0.02 240 / 0.6);
}
</style>

4. Pin column widths when you need them stable

In flow mode the column layout belongs to the browser. With plain auto layout the widths derive from the currently mounted rows, so they can shift as virtualized windows change. For stable columns the component pins a <colgroup> and switches the table to table-layout: fixed: auto-size-columns measures the first window (header plus rows) once and pins those widths, while column-widths pins your own pixel list and takes precedence over auto-sizing. Both require every row (and the header) to emit the same number of cells; otherwise the browser auto layout is kept.

<script setup lang="ts">
type WidthMode = 'auto' | 'first' | 'fixed';
const widthMode = ref<WidthMode>('auto');
const PINNED = [ 90, 260, 360, 140 ]; // px per column
</script>

<template>
  &lt;!--With flow-table you choose how column widths are pinned. Browser auto
    layout (default) sizes columns from the rows currently mounted, so widths
    can shift as windows change; the two pinned modes keep them stable:
    - auto-size-columns: measures the first window (header + rows) once and
      pins it through a &lt;colgroup> + table-layout: fixed. Needs every row to
      emit the same number of direct cells, or the browser auto layout wins.
    - column-widths: your own px per column, pinned the same way and taking
      precedence over auto-size-columns. -->
  <VirtualScrollTable
    class="members"
    :items="members"
    flow-table
    :auto-size-columns="widthMode === 'first'"
    :column-widths="widthMode === 'fixed' ? PINNED : []"
    aria-label="Members table"
  >
    <template #header>
      <tr>
        <th>#</th>
        <th>Name</th>
        <th>Email</th>
        <th>Role</th>
      </tr>
    </template>

    <template #item="{ item }">
      <td class="num">{{ item.id }}</td>
      <td>
        <div class="name">{{ item.name }}</div>
        <div v-for="(line, i) in item.meta" :key="i" class="meta">{{ line }}</div>
      </td>
      <td class="email">{{ item.email }}</td>
      <td><span class="badge">{{ item.role }}</span></td>
    </template>
  </VirtualScrollTable>
</template>

5. Know when flow mode applies

Real flow is a vertical-only mode: it needs a single vertical axis with no per-row gap, no scroll padding, no sticky indices, and no column grid, and it requires the total content height to stay below the browser's ~10M px scroll limit (above that, coordinate scaling kicks in and rows return to absolute positioning). Outside those constraints the component automatically falls back to its default absolute row layout - rows positioned at engine-computed pixel offsets instead of in real flow - which the Table pattern example demonstrates. Choose flow mode when natural row heights and browser-driven column layout matter more than pixel-exact control.

#NameEmailRoleStatus
00000
Linus Torvalds
Last seen 2h ago
linus.torvalds00000@example.com
AdminAway
00001
Edsger Dijkstra
edsger.dijkstra00001@example.com
AdminBusy
00002
Grace Hopper
grace.hopper00002@example.com
EditorActive
00003
Radia Perlman
In review round
Onboarding buddy
radia.perlman00003@example.com
OwnerBusy
00004
Radia Perlman
Joined 2019
Last seen 2h ago
radia.perlman00004@example.com
EditorActive
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • DOM Items #
  • Total Size (px)
    0h
  • Viewport Size (px)
    0h
  • Scroll Offset (px)
    0y