A hand-rolled windowed list primitive — measured row height, overscan, single-translateY window placement — that keeps 10k rows smooth with complete listbox keyboard navigation.
npx shadcn@latest add @paragon/virtual-list"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>
);
}