Table
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 withcolspan.
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>| ID | Name | Age | City | Role | Status | |
|---|---|---|---|---|---|---|
| #0 | User 0 | user0@example.com | 20 | city1 | Admin | Active |
| #1 | User 1 | user1@example.com | 27 | city2 | Editor | Inactive |
| #2 | User 2 | user2@example.com | 34 | city3 | Viewer | Active |
| #3 | User 3 | user3@example.com | 41 | city4 | Admin | Inactive |
| #4 | User 4 | user4@example.com | 48 | city5 | Editor | Active |
- Scroll Status
- Directionvertical
- Current Item #-
- Rendered Range #0:0
- Total Size (px)0h
- Viewport Size (px)0h
- Scroll Offset (px)0y
- Controls