Virtual List
Data Display

Virtual List

A hand-rolled windowed list primitive — measured row height, overscan, single-translateY window placement — that keeps 10k rows smooth with complete listbox keyboard navigation.

Install

npx shadcn@latest add @paragon/virtual-list

virtual-list.tsx

"use client";

import * as React from "react";
import { Inbox } from "lucide-react";
import { cn } from "@/lib/utils";

export interface VirtualListProps<T>
  extends Omit<React.ComponentProps<"div">, "children" | "onSelect"> {
  items: readonly T[];
  /** Renders one row. Rows are fixed-height windows; keep content one line. */
  renderItem: (
    item: T,
    index: number,
    state: { active: boolean; selected: boolean },
  ) => React.ReactNode;
  /** Stable key per item — keeps scroll/selection stable across data updates. */
  getItemKey?: (item: T, index: number) => React.Key;
  /** Fixed row height in px. Omit to measure it from the first row. */
  rowHeight?: number;
  /** Viewport height in px. */
  height?: number;
  /** Extra rows rendered above/below the viewport. */
  overscan?: number;
  onSelect?: (item: T, index: number) => void;
  defaultSelectedIndex?: number;
  /** Reports the rendered window (inclusive start, exclusive end). */
  onRangeChange?: (start: number, end: number) => void;
  emptyState?: React.ReactNode;
  loading?: boolean;
}

/**
 * A hand-rolled windowed list. Only the visible slice (plus overscan) exists
 * in the DOM; a spacer div owns the true scroll height and the window is
 * placed with a single translateY — no per-row positioning, no library.
 * Scrolling never animates (highest-frequency interaction there is), and the
 * listbox keyboard model is complete: arrows, PageUp/Down, Home/End move the
 * active option and keep it scrolled into view; Enter/Space selects.
 */
export function VirtualList<T>({
  items,
  renderItem,
  getItemKey,
  rowHeight,
  height = 320,
  overscan = 8,
  onSelect,
  defaultSelectedIndex,
  onRangeChange,
  emptyState,
  loading = false,
  className,
  onScroll,
  onKeyDown,
  ...props
}: VirtualListProps<T>) {
  const id = React.useId();
  const viewportRef = React.useRef<HTMLDivElement>(null);
  const probeRef = React.useRef<HTMLDivElement>(null);
  const [scrollTop, setScrollTop] = React.useState(0);
  const [measured, setMeasured] = React.useState<number | null>(null);
  const [activeIndex, setActiveIndex] = React.useState<number>(
    defaultSelectedIndex ?? 0,
  );
  const [selectedIndex, setSelectedIndex] = React.useState<number | null>(
    defaultSelectedIndex ?? null,
  );

  const rowH = rowHeight ?? measured ?? 36;
  const count = items.length;
  const total = count * rowH;

  // Measure the first rendered row once (and on font/container reflow).
  React.useLayoutEffect(() => {
    if (rowHeight !== undefined) return;
    const el = probeRef.current;
    if (!el) return;
    const measure = () => {
      const next = el.offsetHeight;
      if (next > 0) setMeasured((prev) => (prev === next ? prev : next));
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(el);
    return () => observer.disconnect();
  }, [rowHeight, count]);

  const start = Math.max(0, Math.floor(scrollTop / rowH) - overscan);
  const end = Math.min(count, Math.ceil((scrollTop + height) / rowH) + overscan);

  React.useEffect(() => {
    onRangeChange?.(start, end);
  }, [start, end, onRangeChange]);

  const ensureVisible = React.useCallback(
    (index: number) => {
      const viewport = viewportRef.current;
      if (!viewport) return;
      const top = index * rowH;
      const bottom = top + rowH;
      if (top < viewport.scrollTop) viewport.scrollTop = top;
      else if (bottom > viewport.scrollTop + height)
        viewport.scrollTop = bottom - height;
    },
    [rowH, height],
  );

  const moveActive = (next: number) => {
    const clamped = Math.max(0, Math.min(count - 1, next));
    setActiveIndex(clamped);
    ensureVisible(clamped);
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(event);
    if (event.defaultPrevented || count === 0) return;
    const pageSize = Math.max(1, Math.floor(height / rowH));
    switch (event.key) {
      case "ArrowDown":
        event.preventDefault();
        moveActive(activeIndex + 1);
        break;
      case "ArrowUp":
        event.preventDefault();
        moveActive(activeIndex - 1);
        break;
      case "PageDown":
        event.preventDefault();
        moveActive(activeIndex + pageSize);
        break;
      case "PageUp":
        event.preventDefault();
        moveActive(activeIndex - pageSize);
        break;
      case "Home":
        event.preventDefault();
        moveActive(0);
        break;
      case "End":
        event.preventDefault();
        moveActive(count - 1);
        break;
      case "Enter":
      case " ":
        event.preventDefault();
        setSelectedIndex(activeIndex);
        onSelect?.(items[activeIndex], activeIndex);
        break;
    }
  };

  if (loading) {
    return (
      <div
        data-slot="virtual-list"
        aria-busy="true"
        className={cn(
          "w-full overflow-hidden rounded-xl bg-card shadow-border",
          className,
        )}
        style={{ height }}
        {...props}
      >
        <div aria-hidden className="flex flex-col gap-0 px-3 py-2">
          {Array.from({ length: Math.min(12, Math.ceil(height / 36)) }, (_, i) => (
            <div key={i} className="flex h-9 items-center gap-3">
              <div className="size-2 shrink-0 rounded-full bg-muted animate-pulse motion-reduce:animate-none" />
              <div
                className="h-2.5 rounded-sm bg-muted animate-pulse motion-reduce:animate-none"
                style={{ width: `${[62, 78, 45, 70, 55, 82, 40, 66][i % 8]}%`, animationDelay: `${i * 60}ms` }}
              />
            </div>
          ))}
        </div>
      </div>
    );
  }

  if (count === 0) {
    return (
      <div
        data-slot="virtual-list"
        className={cn(
          "flex w-full flex-col items-center justify-center gap-1.5 rounded-xl bg-card text-center shadow-border",
          className,
        )}
        style={{ height }}
        {...props}
      >
        {emptyState ?? (
          <>
            <Inbox aria-hidden className="size-4 text-muted-foreground/60" />
            <p className="text-xs text-muted-foreground">No items</p>
          </>
        )}
      </div>
    );
  }

  return (
    <div
      ref={viewportRef}
      data-slot="virtual-list"
      role="listbox"
      tabIndex={0}
      aria-activedescendant={`${id}-opt-${activeIndex}`}
      onScroll={(event) => {
        onScroll?.(event);
        setScrollTop(event.currentTarget.scrollTop);
      }}
      onKeyDown={handleKeyDown}
      className={cn(
        "w-full overflow-x-hidden overflow-y-auto overscroll-contain rounded-xl bg-card shadow-border",
        "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
        className,
      )}
      style={{ height }}
      {...props}
    >
      <div role="presentation" style={{ height: total, position: "relative" }}>
        <div
          role="presentation"
          style={{ transform: `translateY(${start * rowH}px)` }}
        >
          {items.slice(start, end).map((item, i) => {
            const index = start + i;
            const active = index === activeIndex;
            const selected = index === selectedIndex;
            return (
              <div
                key={getItemKey ? getItemKey(item, index) : index}
                ref={i === 0 ? probeRef : undefined}
                id={`${id}-opt-${index}`}
                role="option"
                aria-selected={selected}
                aria-setsize={count}
                aria-posinset={index + 1}
                onClick={() => {
                  setActiveIndex(index);
                  setSelectedIndex(index);
                  onSelect?.(item, index);
                }}
                className={cn(
                  "relative flex cursor-default items-center overflow-hidden",
                  "transition-colors duration-(--duration-fast)",
                  selected
                    ? "bg-muted/70"
                    : active
                      ? "bg-muted/50"
                      : "hover:bg-muted/40",
                )}
                style={rowHeight !== undefined || measured !== null ? { height: rowH } : undefined}
              >
                {renderItem(item, index, { active, selected })}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}