"use client";
import * as React from "react";
type ItemKey = string | number;
export interface VirtualListScrollOptions {
/** Where the row should land. "auto" scrolls the minimum distance (no-op when already visible). */
align?: "start" | "center" | "end" | "auto";
behavior?: "auto" | "smooth";
/** Also make it the active row (the keyboard cursor). */
activate?: boolean;
}
export interface VirtualListHandle {
scrollToIndex: (index: number, options?: VirtualListScrollOptions) => void;
/** The element that scrolls: the list itself, or `scrollContainer` when given. */
getScrollElement: () => HTMLElement | null;
}
export interface VirtualListRowState {
index: number;
/** The keyboard cursor is on this row. */
active: boolean;
selected: boolean;
}
export interface VirtualListRange {
/** First and last item index inside the viewport (overscan excluded). */
start: number;
end: number;
/** Item rows currently mounted (viewport + overscan + the active row). */
rendered: number;
}
export interface VirtualListProps<T> extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onSelect" | "role"> {
items: readonly T[];
/** Renders one row. Rows may have any height; each is measured with a ResizeObserver. */
renderItem: (item: T, state: VirtualListRowState) => React.ReactNode;
/** Stable key per item (defaults to the index). Measured heights are cached by key. */
getKey?: (item: T, index: number) => ItemKey;
/** Accessible name of the listbox. */
label: string;
/** Height guess for rows that haven't been measured yet, in px. */
estimateSize?: number;
/** Extra rows mounted above and below the viewport. */
overscan?: number;
/** Viewport height in px when the list scrolls itself (ignored with `scrollContainer`). */
height?: number;
/** Let an outer element do the scrolling; the list renders at full height inside it. */
scrollContainer?: React.RefObject<HTMLElement | null>;
/** Group name per item. Consecutive items with the same group share a header. */
getGroup?: (item: T, index: number) => string;
/** Pin the current group's header to the top while scrolling (needs `getGroup`). */
stickyHeaders?: boolean;
renderGroupHeader?: (group: string) => React.ReactNode;
/** Fade newly mounted rows in. Off by default; ignored with reduced motion. */
animateEntry?: boolean;
/** Space between rows in px. */
gap?: number;
/** Controlled selection (item key). */
selectedKey?: ItemKey | null;
defaultSelectedKey?: ItemKey | null;
/** Called on click, Enter or Space. */
onSelect?: (item: T, index: number) => void;
onActiveIndexChange?: (index: number) => void;
onRangeChange?: (range: VirtualListRange) => void;
/** Focus and selection colour. */
accent?: string;
ref?: React.Ref<VirtualListHandle>;
}
interface Layout {
/** Item index for each entry, or -1 for a group header. */
item: Int32Array;
key: string[];
group: string[];
offset: Float64Array;
size: Float64Array;
/** Item index → entry index. */
entryOf: Int32Array;
/** Entry indexes of the group headers, in order. */
headers: number[];
total: number;
}
const HEADER_ESTIMATE = 30;
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const round = (n: number) => Math.round(n * 100) / 100;
function buildLayout<T>(
items: readonly T[],
getKey: ((item: T, index: number) => ItemKey) | undefined,
getGroup: ((item: T, index: number) => string) | undefined,
measured: ReadonlyMap<string, number>,
estimate: number,
gap: number,
): Layout {
const groups = getGroup ? items.map((item, i) => getGroup(item, i)) : null;
let headerCount = 0;
if (groups) for (let i = 0; i < groups.length; i++) if (i === 0 || groups[i] !== groups[i - 1]) headerCount++;
const n = items.length + headerCount;
const layout: Layout = {
item: new Int32Array(n),
key: new Array<string>(n),
group: new Array<string>(n),
offset: new Float64Array(n),
size: new Float64Array(n),
entryOf: new Int32Array(items.length),
headers: [],
total: 0,
};
let y = 0;
let e = 0;
const push = (itemIndex: number, key: string, group: string, guess: number) => {
const size = measured.get(key) ?? guess;
layout.item[e] = itemIndex;
layout.key[e] = key;
layout.group[e] = group;
layout.offset[e] = y;
layout.size[e] = size;
y += size + gap;
e++;
};
for (let i = 0; i < items.length; i++) {
const group = groups ? groups[i] : "";
if (groups && (i === 0 || group !== groups[i - 1])) {
layout.headers.push(e);
push(-1, `h:${layout.headers.length}:${group}`, group, HEADER_ESTIMATE);
}
layout.entryOf[i] = e;
push(i, `r:${getKey ? getKey(items[i], i) : i}`, group, estimate);
}
layout.total = Math.max(0, y - gap);
return layout;
}
/** Last entry whose top edge is at or above `y` (binary search over the offsets). */
function entryAt(layout: Layout, y: number) {
let lo = 0;
let hi = layout.offset.length - 1;
if (hi < 0) return -1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (layout.offset[mid] <= y) lo = mid;
else hi = mid - 1;
}
return lo;
}
function visibleRange(layout: Layout, top: number, height: number, overscan: number): [number, number] {
const n = layout.offset.length;
if (n === 0) return [0, -1];
const first = Math.max(0, entryAt(layout, top));
const last = Math.max(first, entryAt(layout, top + height));
return [Math.max(0, first - overscan), Math.min(n - 1, last + overscan)];
}
/** The header whose group is showing at the top, plus the next header (for the push-up effect). */
function stickyAt(layout: Layout, top: number): [number, number] {
const h = layout.headers;
if (!h.length) return [-1, -1];
let lo = 0;
let hi = h.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (layout.offset[h[mid]] <= top + 0.5) lo = mid;
else hi = mid - 1;
}
return [h[lo], h[lo + 1] ?? -1];
}
/**
* A windowed listbox for thousands of rows. Only the rows in (and just around) the viewport are
* mounted; every row is measured with a ResizeObserver, so heights can vary freely. Unmeasured rows
* use an estimate, and when a row above the viewport turns out taller or shorter the scroll position
* is corrected in the same frame, so nothing jumps. It follows the listbox pattern
* (aria-activedescendant, aria-setsize / aria-posinset) with arrow, Page and Home / End keys.
*/
export function VirtualList<T>({
items,
renderItem,
getKey,
label,
estimateSize = 64,
overscan = 6,
height = 420,
scrollContainer,
getGroup,
stickyHeaders = true,
renderGroupHeader,
animateEntry = false,
gap = 0,
selectedKey: selectedKeyProp,
defaultSelectedKey = null,
onSelect,
onActiveIndexChange,
onRangeChange,
accent = "#FF7448",
ref,
className = "",
style,
onKeyDown,
onFocus,
onBlur,
...rest
}: VirtualListProps<T>) {
const uid = React.useId();
const reduceMotion = usePrefersReducedMotion();
const external = scrollContainer !== undefined;
const rootRef = React.useRef<HTMLDivElement>(null);
const stickyRef = React.useRef<HTMLDivElement>(null);
const [measured, setMeasured] = React.useState<ReadonlyMap<string, number>>(() => new Map());
const [view, setView] = React.useState(() => ({ top: 0, height: external ? 640 : height }));
const [activeIndex, setActiveIndexState] = React.useState(-1);
const [selectedInternal, setSelectedInternal] = React.useState<ItemKey | null>(defaultSelectedKey);
const selectedKey = selectedKeyProp !== undefined ? selectedKeyProp : selectedInternal;
const layout = React.useMemo(() => buildLayout(items, getKey, getGroup, measured, estimateSize, gap), [items, getKey, getGroup, measured, estimateSize, gap]);
const [startEntry, endEntry] = visibleRange(layout, view.top, view.height, overscan);
const [stickyEntry] = getGroup && stickyHeaders ? stickyAt(layout, view.top) : [-1];
const activeEntry = activeIndex >= 0 && activeIndex < items.length ? layout.entryOf[activeIndex] : -1;
// Mutable mirrors for listeners (written after commit, never during render).
const live = React.useRef({ layout, overscan, range: [startEntry, endEntry] as [number, number], view, animateEntry, reduceMotion, estimateSize, onActiveIndexChange });
const measuredRef = React.useRef(new Map<string, number>());
const pendingAdjust = React.useRef(0);
const alignTarget = React.useRef<{ index: number; align: NonNullable<VirtualListScrollOptions["align"]>; until: number } | null>(null);
const roRef = React.useRef<ResizeObserver | null>(null);
const pointerFocus = React.useRef(false);
// The outer scroller (if any), read lazily so a ref object created by the parent always resolves.
const containerProp = React.useRef(scrollContainer);
React.useLayoutEffect(() => {
containerProp.current = scrollContainer;
});
const getScroller = React.useCallback((): HTMLElement | null => (external ? (containerProp.current?.current ?? null) : rootRef.current), [external]);
/** Scroll position and viewport height in list coordinates (works for an outer scroller too). */
const metrics = React.useCallback(() => {
const scroller = getScroller();
const root = rootRef.current;
if (!scroller || !root) return { top: 0, height: 0, base: 0 };
if (!external) return { top: scroller.scrollTop, height: scroller.clientHeight, base: 0 };
const base = root.getBoundingClientRect().top - scroller.getBoundingClientRect().top - scroller.clientTop + scroller.scrollTop;
return { top: scroller.scrollTop - base, height: scroller.clientHeight, base };
}, [external, getScroller]);
/** Moves the pinned header up as the next group's header arrives. */
const updateSticky = React.useCallback((top: number) => {
const el = stickyRef.current;
if (!el) return;
const L = live.current.layout;
const [, next] = stickyAt(L, top);
const h = el.offsetHeight;
const push = next >= 0 ? Math.min(0, L.offset[next] - top - h) : 0;
el.style.transform = `translateY(${round(push)}px)`;
}, []);
/** Re-render only when the mounted range would change, not on every scroll frame. */
const sync = React.useCallback(() => {
const m = metrics();
const L = live.current;
const [s, e] = visibleRange(L.layout, m.top, m.height, L.overscan);
if (s !== L.range[0] || e !== L.range[1] || Math.abs(m.height - L.view.height) > 1) setView({ top: m.top, height: m.height });
updateSticky(m.top);
}, [metrics, updateSticky]);
const scrollToIndexInternal = React.useCallback(
(index: number, align: NonNullable<VirtualListScrollOptions["align"]>, behavior: ScrollBehavior) => {
const scroller = getScroller();
const L = live.current.layout;
if (!scroller || index < 0 || index >= L.entryOf.length) return;
const e = L.entryOf[index];
const start = L.offset[e];
const size = L.size[e];
const m = metrics();
const pinned = stickyRef.current?.offsetHeight ?? 0;
let target: number;
if (align === "start") target = start - pinned;
else if (align === "end") target = start + size - m.height;
else if (align === "center") target = start + size / 2 - (m.height + pinned) / 2 + pinned;
else if (start - pinned < m.top) target = start - pinned;
else if (start + size > m.top + m.height) target = start + size - m.height;
else return;
scroller.scrollTo({ top: Math.max(0, m.base + target), behavior });
},
[getScroller, metrics],
);
const scrollToIndex = React.useCallback(
(index: number, options: VirtualListScrollOptions = {}) => {
const { align = "auto", behavior = "auto", activate = false } = options;
const smooth = behavior === "smooth" && !live.current.reduceMotion;
if (activate) {
setActiveIndexState(index);
live.current.onActiveIndexChange?.(index);
}
scrollToIndexInternal(index, align, smooth ? "smooth" : "auto");
// Rows measured after landing can move the target: keep re-aligning briefly (after a smooth scroll ends).
alignTarget.current = { index, align, until: smooth ? Infinity : performance.now() + 400 };
if (smooth) {
const scroller = getScroller();
const settle = () => {
window.clearTimeout(timer);
scroller?.removeEventListener("scrollend", settle);
if (alignTarget.current?.index !== index) return;
alignTarget.current = { index, align, until: performance.now() + 300 };
scrollToIndexInternal(index, align, "auto");
};
const timer = window.setTimeout(settle, 900);
scroller?.addEventListener("scrollend", settle);
}
},
[getScroller, scrollToIndexInternal],
);
React.useImperativeHandle(ref, () => ({ scrollToIndex, getScrollElement: getScroller }), [scrollToIndex, getScroller]);
// After every commit: refresh the mirrors, apply scroll-anchor corrections, keep a target aligned.
React.useLayoutEffect(() => {
live.current = { layout, overscan, range: [startEntry, endEntry], view, animateEntry, reduceMotion, estimateSize, onActiveIndexChange };
const scroller = getScroller();
if (scroller && pendingAdjust.current !== 0) {
scroller.scrollTop += pendingAdjust.current;
pendingAdjust.current = 0;
}
const t = alignTarget.current;
if (t && performance.now() < t.until && t.until !== Infinity) scrollToIndexInternal(t.index, t.align, "auto");
updateSticky(metrics().top);
});
// Scroll + viewport resize listeners on whichever element scrolls.
React.useEffect(() => {
const scroller = getScroller();
if (!scroller) return;
let frame = 0;
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(() => {
frame = 0;
sync();
});
};
const onUserScroll = () => {
// A wheel / touch / key scroll by the user cancels any pending alignment.
alignTarget.current = null;
};
scroller.addEventListener("scroll", onScroll, { passive: true });
scroller.addEventListener("wheel", onUserScroll, { passive: true });
scroller.addEventListener("touchmove", onUserScroll, { passive: true });
const ro = new ResizeObserver(onScroll);
ro.observe(scroller);
onScroll();
return () => {
cancelAnimationFrame(frame);
scroller.removeEventListener("scroll", onScroll);
scroller.removeEventListener("wheel", onUserScroll);
scroller.removeEventListener("touchmove", onUserScroll);
ro.disconnect();
};
}, [getScroller, sync]);
React.useEffect(() => () => roRef.current?.disconnect(), []);
// Report the visible item range (viewport only, overscan excluded).
const firstItem = firstItemFrom(layout, entryAt(layout, view.top), 1);
const lastItem = firstItemFrom(layout, entryAt(layout, view.top + view.height), -1);
let renderedRows = 0;
for (let e = startEntry; e <= endEntry; e++) if (layout.item[e] >= 0) renderedRows++;
if (activeEntry >= 0 && (activeEntry < startEntry || activeEntry > endEntry)) renderedRows++;
React.useEffect(() => {
onRangeChange?.({ start: firstItem, end: lastItem, rendered: renderedRows });
}, [firstItem, lastItem, renderedRows, onRangeChange]);
/** One shared ResizeObserver measures every mounted row and header. */
const observe = React.useCallback((el: HTMLDivElement | null) => {
if (!el) return;
if (!roRef.current) {
roRef.current = new ResizeObserver((entries) => {
const L = live.current.layout;
const scroller = getScroller();
const root = rootRef.current;
let top = scroller?.scrollTop ?? 0;
if (external && scroller && root) top = scroller.getBoundingClientRect().top - root.getBoundingClientRect().top;
let changed = false;
for (const entry of entries) {
const target = entry.target as HTMLElement;
const key = target.dataset.vkey;
if (!key) continue;
const size = round(entry.borderBoxSize?.[0]?.blockSize ?? target.offsetHeight);
const index = Number(target.dataset.ventry);
const previous = measuredRef.current.get(key) ?? (L.key[index] === key ? L.size[index] : size);
if (Math.abs(previous - size) < 0.5 && measuredRef.current.has(key)) continue;
measuredRef.current.set(key, size);
changed = true;
// A row that sits entirely above the viewport changed height: shift the scroll position by
// the same amount so the rows the user is looking at stay put.
if (L.key[index] === key && L.offset[index] + previous <= top && top > 0) pendingAdjust.current += size - previous;
}
if (changed) setMeasured(new Map(measuredRef.current));
});
}
const ro = roRef.current;
ro.observe(el);
const { animateEntry: animate, reduceMotion: reduce } = live.current;
if (animate && !reduce && el.dataset.vkind === "row" && typeof el.animate === "function") {
el.animate([{ opacity: 0, translate: "0 6px" }, { opacity: 1, translate: "0 0" }], { duration: 260, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)" });
}
return () => ro.unobserve(el);
}, [external, getScroller]);
const setActive = (index: number, reveal: boolean) => {
const next = Math.max(0, Math.min(items.length - 1, index));
setActiveIndexState(next);
onActiveIndexChange?.(next);
if (reveal) {
alignTarget.current = { index: next, align: "auto", until: performance.now() + 300 };
scrollToIndexInternal(next, "auto", "auto");
}
};
const select = (index: number) => {
const item = items[index];
if (item === undefined) return;
const key = getKey ? getKey(item, index) : index;
if (selectedKeyProp === undefined) setSelectedInternal(key);
onSelect?.(item, index);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || !items.length) return;
const m = metrics();
const current = activeIndex >= 0 ? activeIndex : -1;
const firstVisible = Math.max(0, firstItemFrom(layout, entryAt(layout, m.top), 1));
let next: number;
switch (event.key) {
case "ArrowDown":
next = current < 0 ? firstVisible : current + 1;
break;
case "ArrowUp":
next = current < 0 ? firstVisible : current - 1;
break;
case "Home":
next = 0;
break;
case "End":
next = items.length - 1;
break;
case "PageDown":
case "PageUp": {
const from = current < 0 ? firstVisible : current;
const y = layout.offset[layout.entryOf[from]] + (event.key === "PageDown" ? m.height : -m.height);
const e = Math.max(0, entryAt(layout, y));
next = firstItemFrom(layout, e, event.key === "PageDown" ? 1 : -1);
if (next < 0) next = event.key === "PageDown" ? items.length - 1 : 0;
break;
}
case "Enter":
case " ":
event.preventDefault();
if (current >= 0) select(current);
return;
default:
return;
}
event.preventDefault();
setActive(next, true);
};
// Mounted entries: the window plus the active row, so aria-activedescendant always resolves.
const mounted: number[] = [];
for (let e = startEntry; e <= endEntry; e++) mounted.push(e);
if (activeEntry >= 0 && (activeEntry < startEntry || activeEntry > endEntry)) {
if (activeEntry < startEntry) mounted.unshift(activeEntry);
else mounted.push(activeEntry);
}
const header = (group: string) =>
renderGroupHeader ? (
renderGroupHeader(group)
) : (
<div className="border-b border-zinc-900/[0.07] bg-zinc-50 px-4 py-2 font-mono text-[10px] uppercase leading-none tracking-[0.14em] text-zinc-500 dark:border-white/[0.07] dark:bg-[#1a1d21] dark:text-zinc-400">
{group}
</div>
);
const rowId = (index: number) => `${uid}-row-${index}`;
const content = (
<div className="relative w-full" style={{ height: round(layout.total) }}>
{stickyEntry >= 0 ? (
<div aria-hidden="true" className="pointer-events-none sticky top-0 z-[2] h-0">
<div ref={stickyRef} className="absolute inset-x-0 top-0">
{header(layout.group[stickyEntry])}
</div>
</div>
) : null}
{mounted.map((e) => {
const index = layout.item[e];
const position: React.CSSProperties = { transform: `translateY(${round(layout.offset[e])}px)` };
if (index < 0) {
return (
<div key={layout.key[e]} ref={observe} data-vkey={layout.key[e]} data-ventry={e} data-vkind="header" aria-hidden="true" className="absolute inset-x-0 top-0" style={position}>
{header(layout.group[e])}
</div>
);
}
const item = items[index];
const key = getKey ? getKey(item, index) : index;
const active = index === activeIndex;
const selected = selectedKey !== null && selectedKey === key;
return (
<div
key={layout.key[e]}
ref={observe}
id={rowId(index)}
role="option"
aria-selected={selected}
aria-setsize={items.length}
aria-posinset={index + 1}
data-vkey={layout.key[e]}
data-ventry={e}
data-vkind="row"
data-active={active || undefined}
data-selected={selected || undefined}
onPointerDown={(event) => {
if (event.button === 0) setActive(index, false);
}}
onClick={() => select(index)}
className="absolute inset-x-0 top-0 cursor-default select-none outline-offset-[-2px] transition-colors duration-100 data-[selected]:bg-[color-mix(in_oklab,var(--vl-accent)_11%,transparent)] group-focus-visible/vl:data-[active]:outline-2 group-focus-visible/vl:data-[active]:outline-solid group-focus-visible/vl:data-[active]:outline-[var(--vl-accent)] [@media(hover:hover)]:hover:bg-zinc-900/[0.03] dark:[@media(hover:hover)]:hover:bg-white/[0.03] [@media(hover:hover)]:data-[selected]:hover:bg-[color-mix(in_oklab,var(--vl-accent)_14%,transparent)]"
style={position}
>
{renderItem(item, { index, active, selected })}
</div>
);
})}
</div>
);
return (
<div
{...rest}
ref={rootRef}
role="listbox"
tabIndex={0}
aria-label={label}
aria-activedescendant={activeIndex >= 0 && activeIndex < items.length ? rowId(activeIndex) : undefined}
onKeyDown={handleKeyDown}
onPointerDownCapture={() => {
pointerFocus.current = true;
}}
onBlur={(event) => {
onBlur?.(event);
pointerFocus.current = false;
}}
onFocus={(event) => {
onFocus?.(event);
const byPointer = pointerFocus.current;
pointerFocus.current = false;
// A click picks its own row; keyboard focus lands on a sensible one.
if (byPointer || event.target !== event.currentTarget || activeIndex >= 0 || !items.length) return;
// First focus: start on the selected row if it is in view, otherwise the first visible row.
const m = metrics();
const firstVisible = Math.max(0, firstItemFrom(layout, entryAt(layout, m.top), 1));
const selectedIndex = selectedKey === null ? -1 : items.findIndex((item, i) => (getKey ? getKey(item, i) : i) === selectedKey);
const se = selectedIndex >= 0 ? layout.entryOf[selectedIndex] : -1;
const inView = se >= 0 && layout.offset[se] >= m.top && layout.offset[se] + layout.size[se] <= m.top + m.height;
setActive(inView ? selectedIndex : firstVisible, false);
}}
className={`group/vl relative isolate w-full rounded-xl border border-zinc-900/10 bg-white text-zinc-900 outline-hidden [overflow-anchor:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--vl-accent)] dark:border-white/10 dark:bg-[#16191c] dark:text-zinc-100 ${external ? "" : "overflow-y-auto overscroll-contain"} ${className}`}
style={{ ["--vl-accent" as string]: accent, ...(external ? null : { height }), ...style }}
>
{content}
</div>
);
}
/** Nearest item index to entry `e`, walking forward (1) or backward (-1) past headers. */
function firstItemFrom(layout: Layout, e: number, dir: 1 | -1) {
const n = layout.item.length;
for (let i = Math.max(0, Math.min(n - 1, e)); i >= 0 && i < n; i += dir) if (layout.item[i] >= 0) return layout.item[i];
return -1;
}