Table

Standard HTML <table> virtualization
Demonstrates usage of custom tags (table, tbody, tr) for semantically correct and accessible tabular data virtualization with 1,000 items. Row height is fixed at 0px.

How to build a feature like this

A data table with thousands of rows is still a table: column headers, row semantics, and CSS table styling matter as much as scroll performance. VirtualScrollTable virtualizes the rows - the same windowing math VirtualScroll applies to list items - while keeping the document a genuine <table>. The scrollable element is the <table> itself; your header lives in a real <thead>, every item becomes its own <tr> inside the <tbody>, and an invisible spacer row holds the total scroll height. Because only the visible window of rows is ever mounted, the browser can no longer derive column widths from the whole dataset - pinning them is the second half of the work.

1. Shape the slots like a real table

VirtualScrollTable renders the table structure; you supply the parts that carry your markup and data:

  • #header - a single <tr> of <th> cells; the component places it inside its <thead>.
  • #item - the cells of one row (<td>s) without a wrapping <tr>; the component emits one <tr> per item and provides { item, index }.
  • #footer - an optional <tr> placed in <tfoot>; a summary row spans every column with colspan.

Class and aria-label pass through to the root <table>, so borders, striping, and captions are ordinary table CSS. That same element is the scroll container, so - like any virtualized list - it needs a definite height (a fixed height, viewport units, or flex-1 min-h-0 inside a flex column) before it can scroll.

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

import '@pdanpdan/virtual-scroll/style.css';

// Real row records: each entry becomes one virtualized <tr>.
const rows = ref(
  Array.from({ length: 100_000 }, (_, id) => ({
    id,
    name: `User ${ id }`,
    email: `user${ id }@example.com`,
    role: id % 3 === 0 ? 'Admin' : 'Editor',
  })),
);
</script>

<template>
  <!-- The scrollable element IS the semantic <table> and needs a definite
       height to scroll. No item-size => rows measured from the cells;
       a numeric :item-size gives uniform rows with O(1) math instead. -->
  <VirtualScrollTable class="data-table" :items="rows" aria-label="Users table">
    <!-- #header: a real <tr>, placed in the component's <thead>. -->
    <template #header>
      <tr>
        <th class="col-id">ID</th>
        <th class="col-name">Name</th>
        <th class="col-email">Email</th>
        <th class="col-role">Role</th>
      </tr>
    </template>
    <!-- #item: the cells only - the component emits the <tr> per row. -->
    <template #item="{ item }">
      <td class="col-id">{{ item.id }}</td>
      <td class="col-name">{{ item.name }}</td>
      <td class="col-email">{{ item.email }}</td>
      <td class="col-role">{{ item.role }}</td>
    </template>
    <!-- #footer (optional): placed in <tfoot>; span all columns. -->
    <template #footer>
      <tr>
        <td colspan="4">{{ rows.length.toLocaleString() }} rows</td>
      </tr>
    </template>
  </VirtualScrollTable>
</template>

<style scoped>
.data-table {
  height: 480px;
}
</style>

2. Choose how row heights are known

Row sizing offers the same two modes as list virtualization. Leave item-size unset (or pass 0) so every mounted row is measured with a ResizeObserver: cells may pad or wrap freely, and the fallback estimate (default-item-size, default 40) is used only until a row mounts. For uniform rows, pass a numeric item-size: positions then resolve arithmetically, at the cost that the rendered cell height must equal that number exactly (padding and borders included). When sizes follow a known per-row pattern, an array or a size function works as well.

3. Pin the column layout

Table layout is driven by the cells present in the DOM - the header plus whatever rows are currently mounted. As the window moves, different cell content would renegotiate column widths and the table would shift under the cursor; content in unmounted rows never contributes at all. Give every column the same explicit width on the header <th> and the body <td>, either with matching width classes or with nth-child rules as below. Each virtualized row <tr> also carries the library class .virtual-scroll-item (the leading spacer row does not), which gives row-pattern selectors a stable hook for striping.

/* Table layout sees only the header plus the mounted window of rows, so
   column widths must be pinned: set the SAME width on the header <th> and
   the body <td> of every column. */
.data-table th.col-id,
.data-table td.col-id {
  width: 6rem;
}

.data-table th.col-name,
.data-table td.col-name {
  width: 14rem;
}

.data-table th.col-email,
.data-table td.col-email {
  width: 18rem;
}

.data-table th.col-role,
.data-table td.col-role {
  width: 8rem;
}

/* Zebra striping and cell chrome are plain CSS. Every virtualized row <tr>
   carries the library class .virtual-scroll-item (the leading spacer <tr>
   does not), so row-pattern selectors can use it to skip the spacer. */
.data-table .virtual-scroll-item:nth-child(even) td {
  background: oklch(50% 0 0 / 0.04);
}

.data-table :is(th, td) {
  padding: 0.5rem 0.75rem;
  border-bottom: 1px solid oklch(50% 0 0 / 0.12);
  white-space: nowrap;
}

When column widths or row heights should follow content instead of CSS, set flow-table: rows stay in real table flow (invisible spacer rows keep the virtual offsets) and the browser sizes rows and columns itself. In that mode two strategies replace hand-pinned widths - auto-size-columns measures the first mounted window and pins it as a <colgroup> with table-layout: fixed, and column-widths pins an explicit pixel array. Both require every row to expose the same number of direct cells. Note the root element is then a plain scroll container wrapping the real table, so size that container; wide pinned tables scroll horizontally on their own axis, because only the vertical axis is virtualized.

<template>
  <!-- Alternative: flow-table keeps rows in real table flow (invisible spacer
     rows hold the virtual offsets), so the browser sizes rows and columns
     from actual content. Column strategies apply in this mode:
     auto-size-columns measures the first mounted window (header + rows) and
     pins the widths via a <colgroup> with table-layout: fixed; column-widths
     pins an explicit number[] instead. Every row must expose the same number
     of direct cells. -->
  <VirtualScrollTable
    class="data-table"
    :items="rows"
    flow-table
    auto-size-columns
    aria-label="Users table"
  >
    <template #header>
      <tr>
        <th>ID</th>
        <th>Name</th>
        <th>Email</th>
      </tr>
    </template>
    <template #item="{ item }">
      <td>{{ item.id }}</td>
      <td>{{ item.name }}</td>
      <td>{{ item.email }}</td>
    </template>
  </VirtualScrollTable>
</template>
IDNameEmailAgeCityRoleStatus
#0User 0user0@example.com20city1AdminActive
#1User 1user1@example.com27city2EditorInactive
#2User 2user2@example.com34city3ViewerActive
#3User 3user3@example.com41city4AdminInactive
#4User 4user4@example.com48city5EditorActive
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • Total Size (px)
    0h
  • Viewport Size (px)
    0h
  • Scroll Offset (px)
    0y
  • Controls