Skip to content

Virtualization ​

Set virtual to opt into windowing: only the visible slice of rows and/or columns renders inside a bounded rdt-scroll container (with leading/trailing spacer rows/columns preserving total scroll size). The default false is byte-identical to a non-virtual table.

rozie
<DataTable :data="$data.rows" virtual maxHeight="400px" :estimateRowHeight="40">
  <Column field="name" header="Name" />
  <Column field="email" header="Email" />
</DataTable>

The virtual value grammar ​

virtual accepts:

  • false (default, off) — byte-identical to a non-virtual table.
  • true or 'rows' — vertical row windowing. true is byte-behavior-identical to every existing consumer; this is unchanged from before this grammar widened.
  • 'columns' — horizontal column windowing: only the visible slice of leaf columns renders.
  • 'both' — both axes windowed at once.

An unrecognised string behaves as false.

Row windowing renders only the visible slice of rows inside the bounded rdt-scroll container, windowing over the full filtered + sorted (pre-pagination) model and suppressing the client pagination chrome, with correct aria-rowcount / aria-rowindex. Column windowing renders only the visible slice of leaf columns inside the same container, with data-col-index carrying the absolute (unwindowed) column position.

Windowing is built on the framework-agnostic @tanstack/virtual-core wired by hand — no per-framework virtual adapter — one Virtualizer instance per windowed axis, sharing the same scroll element. It is tested to 100,000 rows and to 60+ columns on all six targets by a DOM/behavioral VR matrix.

Column windowing ​

virtual='columns' and virtual='both' window the leaf-column axis:

  • Pinned, active, and editing columns always render, regardless of scroll position. A pinned column (getIsPinned()), the active cell's column, and a single-cell in-progress edit are unioned into the rendered slice so a horizontal scroll never unmounts an editor mid-keystroke or a pinned rail out of view. The fill-handle corner and a wide selected range are deliberately not forced — only the columns actually needed to keep the UI coherent.
  • Every header level windows on the same slice as the body, with a clamped colspan. A group header's colSpan clamps to its in-window leaf span; a group entirely outside the window renders no header cell at all. The dedicated filter row windows the same way, keeping its cells aligned with the header and body.
  • Fill-drag gets edge auto-scroll on all four container edges. Dragging a fill handle near the right or left edge of the scroll container auto-scrolls the column axis; near the top or bottom auto-scrolls the row axis (closing a pre-existing gap — a row-windowed table previously could not fill-drag past its own bottom or top edge either).
  • Off-window columns stay fully addressable by absolute index. focusCell / getActiveCell / activecell-change all resolve against the absolute leaf-column position — the same guarantee row windowing already provides on the row axis — so column windowing never narrows what a consumer can drive or observe through the handle.

The table-layout: fixed consequence ​

Turning on column windowing (virtual='columns' or 'both') applies table-layout: fixed to the table. This is a real, documented cost: columns stop auto-fitting their content the way an unwindowed table's columns do, because the horizontal spacer math needs every column's width to be predictable up front rather than resolved after layout.

Size columns explicitly via the two-way :columnSizing slice ({ [columnId]: number }, in px) or the built-in pointer/keyboard resize handle. table-core's column-size oracle (getSize(), defaulting to 150px, with explicit sizes on the select/expander chrome columns) is authoritative for the windowed path.

This consequence applies only to the column-windowed path — a table with virtual={false}, virtual={true}, or virtual='rows' keeps its existing auto-fitting layout untouched.

The bounded container width requirement ​

A column-windowed table needs a bounded width the same way a row-windowed table needs a bounded height (maxHeight) — the windowing engine measures the scroll container's own size to decide which columns are "visible." An unbounded container defeats the point of windowing (every column ends up rendered anyway), so in development mode virtual='columns'/'both' with no bounded width logs a console warning to flag the misconfiguration. There is no maxWidth prop or CSS token mirroring maxHeight: a block-level container already bounds horizontally at 100% of its parent in the common case, so size the table's parent element (or an ancestor) instead.

Auto-measure ​

Set autoMeasure alongside row windowing (virtual='rows'/true/'both') to make the row-height estimate content-driven instead of fixed:

rozie
<DataTable :data="$data.rows" virtual autoMeasure maxHeight="400px">
  <Column field="name" header="Name" />
</DataTable>
  • When autoMeasure is false (default), estimateRowHeight is the explicit, fixed estimate used for every row's size on every render, exactly as before this prop existed.
  • When autoMeasure is true, the windowing engine feeds estimateSize() a running mean of measured row heights instead of the fixed seed. As more rows are scrolled into view and measured, the estimate for never-rendered rows converges toward the true average — so getTotalSize() (and the scrollbar it drives) converges toward the real content total on a large table with variable-height rows, instead of staying pinned at rowCount × estimateRowHeight forever.
  • The re-feed is hysteresis-gated (only re-feeding the estimate when the mean has moved meaningfully) and anchor-preserving: when the estimate refines, the topmost currently-rendered row's own position is held steady so the visible content does not visibly lurch during the refinement.

estimateRowHeight is unchanged and still required. It is not deprecated, superseded, or legacy — it is still read on every first paint, since the very first render has zero measurements regardless of autoMeasure. When autoMeasure is true, later renders progressively replace this seed with the running-mean estimate; when autoMeasure is false, estimateRowHeight remains the explicit, permanent override for every render, exactly as it always has been.

Lazy loading ​

For a long server-side list (a mail folder, an audit log), let the table own the scroll space and fetch only what the viewport needs. Set virtual, manual and rowCount to the server's total, and pass data as a sparse array: an undefined/null entry — or anything past data.length — is a row not loaded yet and renders as a placeholder row (the #placeholder slot, a skeleton bar by default, with aria-busy on the row). Placeholder rows can't be selected, expanded, edited or activated, and select-all takes the loaded rows only.

visible-range-change { start, end } reports the rendered window (overscan included) whenever it changes; fetch that range and write the rows into it. A placeholder is replaced in place, so the scroll position doesn't move. Supply getRowId so a selection follows its row when rows arrive or move.

vue
<DataTable
  :data="rows" virtual manual :row-count="total" max-height="600px"
  :get-row-id="(r) => r.id"
  @visible-range-change="({ start, end }) => fetchRows(start, end)"
>
  <Column field="subject" header="Subject" />
</DataTable>

manual is required: with holes in the data, client-side sorting and filtering have nothing meaningful to sort — the server owns order and filters. Without virtual, rowCount keeps its pagination meaning.

Known limitations ​

None currently open for column windowing. Two limitations documented here previously — the Svelte horizontal-spacer scrollWidth under-report, and grouped-header width divergence under table-layout: fixed on Solid/Svelte/React — were both root-caused and closed, and every case in tests/visual-regression/specs/data-table-grid-column-virtual.spec.ts is now enforced on all six targets with no exclusions.

Scrolling to a row programmatically ​

Windowing means an off-window row has no DOM node yet, so scrolling to it needs the virtualizer, not scrollIntoView. Use the imperative handle's scrollToRow(index, options?) — it forwards TanStack virtual-core's own { align?, behavior? } ScrollToOptions straight to virtualizer.scrollToIndex, and is independent of grid-mode focus (it never moves the roving active cell or fires activecell-change). getScrollElement() returns the rdt-scroll DOM node itself, for a consumer that needs to read scroll position directly rather than reach into the internal class selector. Both are safe no-ops/null on a non-virtual table.

Per-framework code ​

The per-target consumption snippet is the virtualized rows snippet on the usage page; the live demo runs the real Vue package over 50,000 windowed rows.

See also ​

  • API reference — the virtual / autoMeasure / estimateRowHeight / maxHeight props.
  • Comparison — how this compares to the other five frameworks' virtualization stories.

Pre-1.0 — APIs may change between minor versions.