VirtualList

Windowed list that only renders the rows currently within (or near) its viewport, backed by `@tanstack/react-virtual`. Intended to be composed by `Table` (opt-in `virtualize` prop) and `Combobox` (long option lists) — see the props above for what those consumers need: `height` (container height), `estimateSize` (row height estimate), `overscan`, and `renderItem` (render an arbitrary item by index). The forwarded ref resolves to a {@link VirtualListHandle}, not the scroll container element.

organismsSource
import { VirtualList } from '@elirobinson/react/components/organisms/VirtualList';

Styles: @elirobinson/react/styles/organisms/VirtualList.css — already included when you import @elirobinson/react/styles.css.

200 rows, but scroll and check the DOM — only the rows near the viewport actually render.

Show code
import { VirtualList } from '@elirobinson/react/components/organisms/VirtualList';

type Session = {
  id: number;
  customer: string;
  guide: string;
};

const CUSTOMERS = [
  'Jordan Ellis',
  'Priya Nair',
  'Sam Okafor',
  'Maria Gonzalez',
  'Tomás Rivera',
  'Aisha Bello',
  'Liam Chen',
  'Grace Kim',
  'Noah Fischer',
  'Ingrid Larsen',
];

const GUIDES = [
  'Youth Football Fundamentals',
  'Basketball Conditioning',
  'Rugby Contact Basics',
  'Athletics Sprint Mechanics',
  'Netball Footwork Drills',
  'Swim Stroke Technique',
];

const sessions: Session[] = Array.from({ length: 200 }, (_, index) => ({
  id: index + 1,
  customer: CUSTOMERS[index % CUSTOMERS.length],
  guide: GUIDES[index % GUIDES.length],
}));

export default function Basic() {
  return (
    <VirtualList
      items={sessions}
      estimateSize={() => 40}
      height={320}
      renderItem={(session) => (
        <span>
          <strong>#{session.id}</strong> {session.customer} — {session.guide}
        </span>
      )}
    />
  );
}

When to use it

Use VirtualList when you have hundreds or thousands of items and rendering every one to the DOM would make scrolling stutter. It only renders the rows currently within, or just outside, its viewport. Combobox already uses it internally for long option lists, and VirtualTable uses it for windowed table rows — reaching for it directly usually means a plain list, not a table (grab VirtualTable for that).

If the list rarely runs past a few dozen items, skip it — a plain .map() is simpler, and the fixed-height viewport plus row-measurement bookkeeping cost more than they save at that size.

Scrolling to a row

Show code
import { useRef } from 'react';
import { Button } from '@elirobinson/react/components/atoms/Button';
import { VirtualList } from '@elirobinson/react/components/organisms/VirtualList';
import type { VirtualListHandle } from '@elirobinson/react/components/organisms/VirtualList';

const items = Array.from({ length: 200 }, (_, index) => `Row ${index + 1}`);

export default function ScrollToIndex() {
  const listRef = useRef<VirtualListHandle>(null);

  return (
    <div className="demo-col">
      <div className="demo-row">
        <Button variant="secondary" onClick={() => listRef.current?.scrollToIndex(0)}>
          Jump to row 1
        </Button>
        <Button variant="secondary" onClick={() => listRef.current?.scrollToIndex(199)}>
          Jump to row 200
        </Button>
      </div>
      <VirtualList
        ref={listRef}
        items={items}
        estimateSize={() => 36}
        height={280}
        renderItem={(item) => <span>{item}</span>}
      />
    </div>
  );
}

The forwarded ref does not resolve to the scroll container element. A row currently outside the window has no DOM node to scroll to, so the ref exposes a VirtualListHandle with scrollToIndex instead — backed by the underlying virtualizer's own measurements, not manual scrollTop math.

Props

PropTypeDefaultDescription
estimateSizerequired(index: number) => numberEstimated size (px) of the row at `index`, used before it has been measured. Required by the underlying virtualizer even for fixed-height rows — pass a constant function (e.g. `() => 40`) in that case.
heightrequirednumberFixed height (px) of the scrollable viewport.
itemsrequiredT[]The full, un-windowed list of items to virtualize.
renderItemrequired(item: T, index: number) => ReactNodeRenders the item at `index`. Only called for rows in the current window.
overscannumberExtra rows to render outside the visible viewport, above and below.

Accessibility

  • Renders a plain scrollable <div> (overflow: auto) with no ARIA role of its own — it doesn't add list semantics (role="list"/"listitem") on your behalf. VirtualTable layers role="table"/"row"/"cell" on top for that reason; do the same if your content needs it.
  • Keyboard: scrolling is native. The container is a real scrollable element, so once something inside it has focus, arrow keys, Page Up/Down, and the mouse wheel or trackpad all move it through the browser's own scroll handling — nothing custom is layered on top.
  • Rows outside the current window aren't hidden, they don't exist in the DOM yet. Don't rely on find-in-page (Cmd/Ctrl+F) to reach one, or hold a ref to a specific row's element — call scrollToIndex to bring it into range first.
  • estimateSize is required even for uniform rows — pass a constant function like () => 40. Once a row renders, its real measured height replaces the estimate.

Do

  • Always pass an explicit height — the viewport needs a fixed size for the virtualizer to know how many rows fit.
  • Use scrollToIndex from the ref to jump to a row, instead of hand-rolled scrollTop math.
  • Keep renderItem cheap — it runs for every row in the current window on every scroll frame.
  • Pass a larger overscan (5-10) if tall rows make scrolling look like content is popping in.

Don't

  • Expect the ref to be a DOM node — it resolves to a VirtualListHandle with scrollToIndex, not an element you can call .focus() or .scrollTo() on.
  • Nest VirtualList inside another scrollable container without its own fixed height — two independent scroll regions fighting each other is confusing to use.
  • Reach for it under a few dozen items — a plain .map() is simpler and the bookkeeping is not worth it.
  • Assume Cmd/Ctrl+F will find text in a row that has not rendered yet — virtualized rows outside the window are not in the DOM.