Skip to content
WANCRAFT

B.188Cards & Containers

Virtual List

A windowed listbox that renders only the visible rows of a huge list, with measured variable heights, sticky group headers and scrollToIndex.

NewReactTailwindadvanced

Overview

Virtual List keeps a list of ten thousand rows as light as a list of twenty. Only the rows in the viewport, plus a few overscan rows on either side, are mounted. Each row is measured with a single shared ResizeObserver, so rows can be any height. Rows that haven't been measured yet use an estimate, and when a row above the viewport turns out taller or shorter than guessed, the scroll position is corrected in the same frame, so the content you're reading never jumps. Consecutive items can share a group header, and the current one can stay pinned at the top and get pushed up by the next one. A ref handle exposes scrollToIndex with start / center / end / auto alignment. The list follows the listbox pattern: the arrow keys, Page Up / Down and Home / End move the active row and keep it in view, and Enter or Space selects it. The list can scroll itself or render at full height inside a scroll container you pass in. Newly mounted rows can fade in, but that is off by default.

01Installation

Method 01 · Registry

CLI verified

Uses the shadcn CLI in a project initialised with shadcn init. Installs the file to components/ui/virtual-list.tsx and adds its dependencies.

npx shadcn@latest add https://wancraft.my/r/virtual-list.json

Method 02 · Copy and paste

  1. 1. No extra dependencies — React and Tailwind CSS only.
  2. 2. Copy the source from the Code tab into components/ui/virtual-list.tsx.
  3. 3. Import it and adjust props.
Dependencies
None
Peer
react ≥ 18, tailwindcss ≥ 4
Added
13 Oct 2026
Updated
—

02Usage

The default configuration. Change properties above to generate a tailored example in the Usage tab.

example.tsx
import { VirtualList, type VirtualListHandle } from "@/components/ui/virtual-list";
import { useRef } from "react";

export function VirtualListExample() {
  const list = useRef<VirtualListHandle>(null);
  const rows = Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Row ${i + 1}` }));

  return (
    <VirtualList
      ref={list}
      label="Rows"
      items={rows}
      getKey={(row) => row.id}
      renderItem={(row) => <div className="px-4 py-3 text-sm">{row.name}</div>}
    />
  );
}

03Props

PropTypeDefaultDescription
itemsreadonly T[]—Required. The full data set; only visible rows are rendered.
renderItem(item: T, state: { index; active; selected }) => ReactNode—Required. Renders one row. Heights may vary; every row is measured.
labelstring—Required. Accessible name of the listbox.
getKey(item: T, index: number) => string | number—Stable key per item (defaults to the index). Measured heights are cached by key.
estimateSizenumber64Height guess in px for rows that haven't been measured yet.
overscannumber6Extra rows mounted above and below the viewport.
heightnumber420Viewport height in px when the list scrolls itself.
scrollContainerRefObject<HTMLElement | null>—Use an outer scroll container instead; the list renders at full height inside it.
getGroup(item: T, index: number) => string—Group name per item; consecutive items with the same group share a header.
stickyHeadersbooleantruePin the current group header to the top; the next one pushes it up.
renderGroupHeader(group: string) => ReactNode—Custom group header (default: a small mono label).
animateEntrybooleanfalseFade newly mounted rows in (ignored with reduced motion).
gapnumber0Space between rows in px.
selectedKeystring | number | null—Controlled selection.
defaultSelectedKeystring | number | nullnullInitial selection when uncontrolled.
onSelect(item: T, index: number) => void—Called on click, Enter or Space.
onActiveIndexChange(index: number) => void—Called when the keyboard cursor moves.
onRangeChange(range: { start; end; rendered }) => void—Visible item range and the number of mounted rows.
refRef<VirtualListHandle>—{ scrollToIndex(index, { align, behavior, activate }), getScrollElement() }.
accentstring"#FF7448"Focus ring and selection tint.
classNamestring—Extra classes for the listbox element.

04Features

  • 01Windowing: only the viewport plus overscan rows are mounted, whatever the list length
  • 02Variable row heights, measured by one shared ResizeObserver and cached by key
  • 03Estimate-then-correct: when rows above the viewport change height, the scroll position is compensated in the same frame, so nothing jumps
  • 04scrollToIndex with start / center / end / auto alignment and smooth scrolling, re-aligned once the target rows are measured
  • 05Optional group headers, with the current one pinned and pushed up by the next
  • 06Scrolls itself, or renders inside a scroll container you pass in
  • 07Optional fade-in for newly mounted rows (off by default)
motion · tweeninput · scrollinput · keyboardinput · click

05Accessibility

  • Listbox pattern: the list is a focusable role="listbox" and rows are role="option" with aria-selected; the active row is announced through aria-activedescendant.
  • Every row has aria-setsize (the full item count) and aria-posinset, so screen readers report "row 4,127 of 10,000" even though most rows aren't in the DOM.
  • Arrow Up / Down move the active row, Page Up / Down move by a screenful, Home / End jump to the ends, and Enter / Space select. The active row is always kept in view and always mounted, so the active descendant always resolves.
  • The active row gets a visible focus ring only while the list has keyboard focus; selection is shown with a tint.
  • Group headers are decorative (aria-hidden). If the group matters, include it in the row content.
  • The entry animation is off by default and is skipped entirely with prefers-reduced-motion, and smooth scrollToIndex becomes an instant jump.

06Limitations

  • Rows are absolutely positioned, so browser find-in-page (Ctrl+F) only finds rows that are currently mounted.
  • Single selection only. Rows should not contain their own tab stops; use a grid pattern for interactive cells.