Skip to content

Virtual rows ​

Person 1Engineering$50,0002015-01-01
Person 2Design$57,9192016-02-02
Person 3Sales$65,8382017-03-03
Person 4Support$73,7572018-04-04
Person 5Engineering$81,6762019-05-05
Person 6Design$89,5952020-06-06
Person 7Sales$97,5142021-07-07
Person 8Support$105,4332022-08-08
Person 9Engineering$113,3522023-09-09
Person 10Design$121,2712015-10-10
Person 11Sales$129,1902016-11-11
Person 12Support$137,1092017-12-12
Person 13Engineering$55,0282018-01-13
Person 14Design$62,9472019-02-14
Person 15Sales$70,8662020-03-15
Person 16Support$78,7852021-04-16
Person 17Engineering$86,7042022-05-17
Person 18Design$94,6232023-06-18
Person 19Sales$102,5422015-07-19
Person 20Support$110,4612016-08-20
Person 21Engineering$118,3802017-09-21
Person 22Design$126,2992018-10-22
Person 23Sales$134,2182019-11-23
Person 24Support$52,1372020-12-24
Person 25Engineering$60,0562021-01-25
Person 26Design$67,9752022-02-26
Person 27Sales$75,8942023-03-27
Person 28Support$83,8132015-04-28
Person 29Engineering$91,7322016-05-01
Person 30Design$99,6512017-06-02
Person 31Sales$107,5702018-07-03
Person 32Support$115,4892019-08-04
Person 33Engineering$123,4082020-09-05
Person 34Design$131,3272021-10-06
vue
<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.

vue
<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
virtualOn or off. Off is the default and off is unchanged.
row-heightHow tall one row is, in CSS pixels. Defaults to 38.
overscanRows kept rendered beyond each edge. Defaults to OVERSCAN_ROWS, which is 4.
measure-rowsMeasure 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:

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