Experience System

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-virtual

For 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 render
import { 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) and overscan (extra rows outside the viewport so fast scrolling doesn't flash blank; 5–10 is plenty). For variable heights, add ref={virtualizer.measureElement} and data-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.

ID
Name
Email
Amount

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.

0 selected
Select all

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 gapsestimateSize is far from the real height, or rows vary (use measureElement).
  • First screen renders, then blanks on scrollgetScrollElement isn't returning the element that actually scrolls. Put the ref on the scrolling div.

Virtualization vs lazy loading

Complementary, not interchangeable:

VirtualizationLazy loading
WhatRender only visible rows of data already in memoryFetch data incrementally as needed
SolvesDOM / render costTransfer / memory cost
Use whenAll rows are client-side but too many to renderData 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:

WhatReference
useVirtualizer options (count, getScrollElement, estimateSize, …)Virtualizer API
useVirtualizer vs useWindowVirtualizerReact 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-tree and virtualized-multiselect-tree
  • list-collector and virtual-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-collector for native rendering, or @by-es/virtual-list-collector when a panel holds enough rows to be worth windowing. The virtualized block keeps ListCollectorRoot as 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.