Virtualization
Render large lists, tables, and trees performantly by windowing the DOM with TanStack Virtual, a headless library you wire into your own components.
Virtualization renders only the rows visible in the viewport instead of every row in your data. A 100,000-row list keeps ~15 nodes in the DOM, so scrolling stays smooth and memory stays flat.
Blue Yonder components are headless and composition-first, so virtualization is a pattern you wire in, not a feature the library owns — you keep full control of markup, tokens, and accessibility.
TanStack Virtual
We standardize on TanStack Virtual.
It's headless (you own the DOM) and the native partner of TanStack Table v8, which
@by/experience-system-table-core already uses. You call useVirtualizer directly — we deliberately
don't ship a wrapper, so the recognizable TanStack API stays the one you learn.
Installation
pnpm add @tanstack/react-virtualFor a virtualized table, also use @by/experience-system-table-core for the row/column model.
The pattern
Every virtualized component is the same shape: a scroll container, a spacer sized to the full list, and only the visible rows positioned absolutely.
scroll container — fixed height, overflow:auto → getScrollElement()
└── spacer — height: getTotalSize() → reserves room for every row
└── virtual row — absolute, translateY(start) → only the visible ones renderimport { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
function VirtualList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 8,
});
return (
<div ref={parentRef} className="h-80 overflow-auto">
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
className="absolute inset-x-0 top-0 flex items-center"
style={{ height: row.size, transform: `translateY(${row.start}px)` }}
>
{items[row.index]}
</div>
))}
</div>
</div>
);
}Two things matter:
- A fixed-height scroll container with
overflow: auto. That element is what the virtualizer measures; without a bounded height it renders everything (or nothing). estimateSize(row height in px — self-corrects as rows render) andoverscan(extra rows outside the viewport so fast scrolling doesn't flash blank; 5–10 is plenty). For variable heights, addref={virtualizer.measureElement}anddata-index={row.index}to each row.
List
The pattern verbatim, over 10,000 rows.
Data table
Own the markup, so you wire the windowing: use @by/experience-system-table-core for the row/column model,
lay rows out as a CSS grid so header and body columns align, then window the rows with useVirtualizer.
useTable paginates by default, so read getCoreRowModel().rows (not getRowModel().rows) — the
virtualizer is your pagination. Keep the scroll container inside your component; don't wrap the
prop-driven ExperienceSystemTable from the outside or the virtualizer measures the wrong element.
Tree
A tree is a list with two extra concerns: hierarchy (indent, expand/collapse) and, for a picker, multi-select. Windowing is identical to the list — the difference is what you feed it.
You can't window a nested tree — flatten it first. The Collapsible-based TreeItemGroup markup
isn't windowable. On each render, walk the tree and emit only the visible rows (a branch's children only
when it's expanded), each carrying its depth; window that flat list. Flattening is O(visible).
function flattenVisible(nodes: TreeNode[], expanded: Set<string>): Row[] {
const rows: Row[] = [];
const walk = (list: TreeNode[], depth: number) => {
for (const node of list) {
const isBranch = !!node.children?.length;
const isExpanded = isBranch && expanded.has(node.id);
rows.push({ node, depth, isBranch, isExpanded });
if (isExpanded) walk(node.children!, depth + 1); // children only when open
}
};
walk(nodes, 0);
return rows;
}Render each flat row from the real Tree row parts — TreeItemTrigger,
TreeIndicator, TreeItemContent, TreeItemLabel accept explicit depth / selected / hasChildren /
expanded / size props precisely so windowed rows look identical to nested ones without re-creating any
markup. The pre-wired, copy-owned version — selection, Select All, debounced search, keyboard, measured to
~100k — is the
virtualized multi-select tree recipe.
Accessibility
Windowing removes off-screen rows from the DOM, so find-in-page and a screen reader's row counts only see
what's rendered. Drive aria-rowcount (tables) or aria-setsize / aria-posinset (lists) from the
data, not the rendered window — and for trees compute aria-setsize / aria-posinset per sibling
set with aria-level per depth, as the ARIA tree pattern requires. Keep the right roles (row,
listitem, treeitem) on the windowed elements. Collections below your threshold render natively and
keep default behavior.
Troubleshooting
- Nothing renders, or everything renders — the scroll container has no fixed height. Give it a height
and
overflow: auto. - Rows overlap or leave gaps —
estimateSizeis far from the real height, or rows vary (usemeasureElement). - First screen renders, then blanks on scroll —
getScrollElementisn't returning the element that actually scrolls. Put therefon the scrollingdiv.
Virtualization vs lazy loading
Complementary, not interchangeable:
| Virtualization | Lazy loading | |
|---|---|---|
| What | Render only visible rows of data already in memory | Fetch data incrementally as needed |
| Solves | DOM / render cost | Transfer / memory cost |
| Use when | All rows are client-side but too many to render | Data is large/expensive or server-side |
For a huge server-driven dataset, use both: lazy-load pages (e.g. TanStack Query useInfiniteQuery)
and window the rendering, fetching the next page as the virtual range nears the end.
API Reference
The hook, its options, and its types all come from TanStack Virtual:
| What | Reference |
|---|---|
useVirtualizer options (count, getScrollElement, estimateSize, …) | Virtualizer API |
useVirtualizer vs useWindowVirtualizer | React adapter |
VirtualItem shape from getVirtualItems() | VirtualItem |
| Row/column model for the table example | @by/experience-system-table-core · TanStack Table |
Registry blocks
The list and table are patterns you wire in yourself. The tree and the list collector ship as pre-wired, copy-owned registry blocks, so you can pull them in and own the source:
multiselect-treeandvirtualized-multiselect-treelist-collectorandvirtual-list-collector— a dual-panel transfer list. Windowing is opt-in at install time rather than through a runtime threshold prop: add@by-es/list-collectorfor native rendering, or@by-es/virtual-list-collectorwhen a panel holds enough rows to be worth windowing. The virtualized block keepsListCollectorRootas the single source of truth for panel membership, selection and search, and windows only the rendering.
More blocks can follow the same shadcn-style model.