Virtual rows
| Person 1 | Engineering | $50,000 | 2015-01-01 |
| Person 2 | Design | $57,919 | 2016-02-02 |
| Person 3 | Sales | $65,838 | 2017-03-03 |
| Person 4 | Support | $73,757 | 2018-04-04 |
| Person 5 | Engineering | $81,676 | 2019-05-05 |
| Person 6 | Design | $89,595 | 2020-06-06 |
| Person 7 | Sales | $97,514 | 2021-07-07 |
| Person 8 | Support | $105,433 | 2022-08-08 |
| Person 9 | Engineering | $113,352 | 2023-09-09 |
| Person 10 | Design | $121,271 | 2015-10-10 |
| Person 11 | Sales | $129,190 | 2016-11-11 |
| Person 12 | Support | $137,109 | 2017-12-12 |
| Person 13 | Engineering | $55,028 | 2018-01-13 |
| Person 14 | Design | $62,947 | 2019-02-14 |
| Person 15 | Sales | $70,866 | 2020-03-15 |
| Person 16 | Support | $78,785 | 2021-04-16 |
| Person 17 | Engineering | $86,704 | 2022-05-17 |
| Person 18 | Design | $94,623 | 2023-06-18 |
| Person 19 | Sales | $102,542 | 2015-07-19 |
| Person 20 | Support | $110,461 | 2016-08-20 |
| Person 21 | Engineering | $118,380 | 2017-09-21 |
| Person 22 | Design | $126,299 | 2018-10-22 |
| Person 23 | Sales | $134,218 | 2019-11-23 |
| Person 24 | Support | $52,137 | 2020-12-24 |
| Person 25 | Engineering | $60,056 | 2021-01-25 |
| Person 26 | Design | $67,975 | 2022-02-26 |
| Person 27 | Sales | $75,894 | 2023-03-27 |
| Person 28 | Support | $83,813 | 2015-04-28 |
| Person 29 | Engineering | $91,732 | 2016-05-01 |
| Person 30 | Design | $99,651 | 2017-06-02 |
| Person 31 | Sales | $107,570 | 2018-07-03 |
| Person 32 | Support | $115,489 | 2019-08-04 |
| Person 33 | Engineering | $123,408 | 2020-09-05 |
| Person 34 | Design | $131,327 | 2021-10-06 |
<script setup lang="ts">
import { shallowRef } from 'vue'
import { DataTable, useLocalDataSource, useTableState, type ColumnDef } from '@brillliand/vue-table-chad'
import '@brillliand/vue-table-chad/style.css'
type Person = { id: number; name: string; department: string; salary: number; hiredAt: string }
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', type: 'text', pinned: 'left' },
{ id: 'department', header: 'Department', type: 'enum', options: ['Engineering', 'Design', 'Sales', 'Support'] },
{
id: 'salary',
header: 'Salary',
type: 'number',
align: 'right',
format: (v) => (v == null ? '—' : `$${Number(v).toLocaleString()}`),
},
{ id: 'hiredAt', header: 'Hired', type: 'date' },
]
function makePeople(count: number): Person[] {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Person ${i + 1}`,
department: ['Engineering', 'Design', 'Sales', 'Support'][i % 4]!,
salary: 50_000 + ((i * 7919) % 90_000),
hiredAt: new Date(2015 + (i % 9), i % 12, 1 + (i % 28)).toISOString().slice(0, 10),
}))
}
// 100k rows in a shallowRef. `virtual` makes the page size everything and
// renders only the rows inside the scroll window.
const rows = shallowRef<Person[]>(makePeople(100_000))
const state = useTableState({ pageSize: 25 })
const source = useLocalDataSource(rows, columns, state.query)
</script>
<template>
<DataTable
:columns="columns"
:source="source"
:state="state"
virtual
:row-height="38"
:overscan="8"
/>
</template>A hundred thousand rows as one continuous scroll, with about thirty of them in the document.
<DataTable :columns="columns" :source="source" :state="state" virtual />Off by default. A table that does not ask for it keeps its pager and renders exactly the markup it rendered before — the same <tbody>, the same rows, no spacers.
What virtual actually changes
Two things, and only two.
The page becomes everything. virtual writes the page size to the size of the result set. That is the whole of it in core/: there is no second row path, no separate un-paged list, and QueryState.pageSize stays a truthful record of what was asked for — a URL mirroring the query still describes the table you are looking at. Everything downstream keeps the meaning it already had, because "the page" has simply become the whole set.
The <tbody> renders a window. VirtualBody puts one spacer row above the window and one below, each as tall as the rows it stands in for, so the <table> is its full height and the scrollbar is honest while the DOM is not.
The pager is not rendered, whatever show-pagination says. There is exactly one page.
The knobs
| Prop | |
|---|---|
virtual | On or off. Off is the default and off is unchanged. |
row-height | How tall one row is, in CSS pixels. Defaults to 38. |
overscan | Rows kept rendered beyond each edge. Defaults to OVERSCAN_ROWS, which is 4. |
measure-rows | Measure each rendered row rather than trusting row-height. Off by default. |
row-height is written to --vtc-row-height on the scroll box, so the number the windowing counts with and the number the browser lays out with cannot drift apart. Change the prop, not the token — setting --vtc-row-height in your own CSS while virtual is on gives the two different answers, and the window starts landing a little further off with every row.
Heights are assumed uniform by default. A group header row lays out about a pixel taller than a data row, which shifts the window's own rows by that much and nothing more — the spacers are computed from the assumed height, and only the rendered rows are laid out, so the error is bounded by the window rather than accumulating down the list.
measure-rows removes the assumption: every rendered row reports its real height, and the offsets, the spacers and the scrollbar follow it. It costs one forced layout per update, on the rows in the window, which is why it is opt-in — a uniform body is exact without it. A row that measures exactly row-height is not recorded at all, so a body that turns out to be uniform anyway pays for the measuring and nothing else. Measurements describe indices in a list, so they are dropped when the list changes and taken again on the next render.
It needs a box with a height
The window is "how many rows fit in the viewport", so there has to be a viewport. .vt-scroll caps itself at 70vh, which is where the height comes from by default. A theme that sets max-height: none on it has made the viewport as tall as the content and turned virtualization off — every row will be rendered, correctly and slowly.
Lifting the cap is fine as long as something else bounds the box. A full-height table does exactly that, and Styling has the flex recipe for it.
Before the box has been measured — the first render, always — the window falls back to an assumed viewport and narrows on the next frame. That is deliberate: a table that rendered nothing until it had been measured would flash empty on every mount.
The cursor
cell-cursor works with virtual, and the combination is the reason two things exist.
A cursor position is a row id, so moving the ring onto a row the window has evicted is legal and does exactly the right thing to the model. What it cannot do is take the focus, because there is no cell in the document to focus. So the body scrolls the window to that row and asks for the focus again — which is why holding ↓ walks the ring off the bottom of the window and the window follows it.
The roving tabindex needs the other half. The cursor walks every row, but the one cell carrying tabindex="0" has to be one a Tab can reach, so while the ring is scrolled out of view the tab stop falls to the first rendered row. Without that the grid would drop out of the tab order entirely whenever you scrolled away from the ring.
Ctrl/Cmd + ←/→ — turn the page — does nothing here, since there are no pages. Ctrl/Cmd+↑/↓ is what replaces it: one screenful of scroll, with the ring left where it was. PageUp/PageDown still move ten rows, and still work.
What it costs
Worth being straight about, because it is not free.
The window is: scrolling is one ref write and two integer divisions, and a scroll that moves less than one row height propagates nothing at all. tests/invalidation.spec.ts holds it to that — a scroll may not run the filter, the sort, the grouping or the aggregates.
The page size is not. Every one of those passes now runs over the whole dataset on each change rather than over 25 rows. At 100k that is roughly 190ms for a search to settle and 65ms to build a two-level group tree — the same work any table filtering 100k rows does, arriving in one place instead of being hidden by a page slice. bench/BASELINE.md has the numbers.
Selection used to be the exception that scaled with the interaction rather than with the data — the header checkbox's tri-state asked "are all of these selected" over every row it was handed, and virtual mode hands it the dataset. It now counts from the selection instead, so a click costs the same at 100k as it does on a page of 25.
Column widths are measured once, from the first window that has rows, and never again while you scroll — a width recomputed per window would twitch as taller or longer values came into view, and at 100k rows there is always a longer value. See Column layout for remeasureColumns(), which is how you ask for a new answer after replacing the data.
Where you land when the list changes
Fold a band shut while scrolled deep and every row below it moves up by the height the band was holding — 600k pixels, at 100k rows. The browser leaves scrollTop where it was, so the viewport would silently be somewhere else in the data.
VirtualBody takes an item-key and anchors the offset to it. The row at the top of the viewport goes back to the top of the viewport, down to the pixel it was scrolled past by; if that row was inside the band that just closed, the nearest surviving item above it — the band's own header row — takes the top instead, which is where "where did I go" ought to answer. The preset passes the same key its v-for uses, so this is on by default.
useVirtualRows takes the same itemKey and does the arithmetic; without one it installs no watcher at all and the offset stays a number of pixels.
What a screen reader is told
A windowed <tbody> holds about thirty rows however long the list is, and the DOM is the only thing an assistive technology can count — so without help a table of 100,000 announces itself as a table of thirty, and the row you are on is "row 4 of 30" wherever you have scrolled to.
In virtual mode the preset sets aria-rowcount on the <table> and aria-rowindex on every rendered row, header rows included and group headers included, numbered over the whole list rather than over the window. A source that has not answered yet reports -1, which is ARIA's way of saying "many, and not known yet"; an infinite source reports the server's count rather than what it has loaded, because that is the size of the thing being scrolled.
Paged, neither attribute is set. Every row of the page is in the document, the pager says which page it is, and numbering each page's rows 1..25 again would be a second and worse answer to the same question.
The spacer rows are aria-hidden: they are geometry, not rows.
Server sources
virtual sets the page size to the size of the result set, not to some fixed window — so a server request fetches the whole matching set in one round trip rather than a page of it. That is what Using it in another project means by "every request fetches the whole matching set rather than a page" when it warns about turning virtual on over a DataSource that hits a network.
A total of zero is "nothing to size to", not "size to nothing". A server source starts at total: 0 and stays there until its first response lands; without a guard, the watcher that sizes the page to the total is immediate and fires on mount, writing a page size of 1 — a real query change, so useServerDataSource would refetch at pageSize: 1 and every virtual server-backed table would spend one wasted round trip fetching a single row before the real total arrived. useTable guards exactly this case: a total of zero or a filter that matches nothing leaves the page size alone rather than shrinking it to fit nothing.
Composing your own
useVirtualRows is pure arithmetic over three numbers — item height, viewport height, scroll offset — and knows nothing about tables:
import { useVirtualRows } from '@brillliand/vue-table-chad'
const virtual = useVirtualRows(items, { rowHeight: 38, viewportHeight: () => box.value?.clientHeight ?? 0 })
box.value.addEventListener('scroll', () => virtual.setScrollOffset(box.value.scrollTop), { passive: true })It returns start, end, the windowed items, spaceBefore, spaceAfter and totalSize, plus offsetFor(index), indexAt(offset) and measureItem(index, height) — report a height and the two lookups become a prefix sum and a binary search over it, which is why they were functions and the spacers were opaque pixel totals from the start. Pass itemKey as well and the scroll offset follows the item it pointed at when the list changes under it. VirtualBody is the <tbody> around it, and it yields the window through its default slot rather than looping itself, so the markup for a row stays yours.
Live: the Virtual rows tab of pnpm demo (#virtual). Back to the docs index.