Display
Virtual List
A hand-rolled windowed list — renders only the visible rows (+ overscan) of a huge array, with fixed or measured variable row heights, imperative scroll control, and infinite-scroll support.
Preview in your theme
Loading preview…
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/virtual-list.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind generic "VirtualList<T>" component with
no virtualization library — hand-roll the windowing math (no
@tanstack/react-virtual, no react-window).
Contract
- export interface VirtualListHandle { scrollToIndex(index, opts?: { align?:
"start" | "center" }): void; scrollToOffset(offset: number): void }
- export interface VirtualListProps<T> extends
Omit<React.HTMLAttributes<HTMLDivElement>, "children">:
items: T[]; renderItem: (item: T, index: number) => ReactNode;
estimateSize: number | ((index: number) => number); getKey?: (item: T,
index: number) => React.Key; overscan? (default 4); height?: number|string
(default 400); gap? (default 0); onRangeChange?: (range: {start, end}) =>
void; onEndReached?: () => void; endThreshold? (default 200); emptyState?:
ReactNode; label? (default "Virtualized list").
- Input clamps: an estimate that is not a finite number > 0 falls back to 40px
and every estimate is floored at 4px (a 0 estimate would stack every row at
top 0 and mount the whole list); overscan is floored to an integer >= 0, and
a non-finite overscan falls back to 4 (negative or NaN would render nothing).
- onRangeChange goes through a latest-ref, and the notifying effect depends only
on [start, end, count] — never on the callback. The natural call site is an
inline arrow (`onRangeChange={r => setRange(r)}`), a new function every render:
with it in the deps the effect would re-fire every render, setState a fresh
object, re-render, and loop until React throws "Maximum update depth exceeded".
- The absolutely-positioned sizer between the scroll container and the rows needs
role="presentation", or the role="list" -> role="listitem" ownership chain
breaks and assistive tech announces an empty list despite the aria-posinset /
aria-setsize wiring on every row.
- Exported as a generic function component so callers keep their element type
through `items`/`renderItem` — forwardRef alone erases the generic, so wrap
the render function in forwardRef and re-cast the exported value to a
generic call signature.
Behavior
- Layout is a pure prefix-sum: for every index, size = measurements.get(key)
?? clamp(typeof estimateSize === "function" ? estimateSize(index) :
estimateSize); top = running cursor; cursor += size + gap (no trailing gap
after the last row). Memoize this on [items, measurements, estimateSize,
gap, getKey] — it must NOT recompute on every scroll event.
- On every scroll event (not in an effect body): read scrollTop from
e.currentTarget, setState it, and separately compute distanceToBottom =
scrollHeight - scrollTop - clientHeight for the end-reached check.
- The rendered window [start, end] comes from two binary searches over the
memoized layout array (O(log n), safe to run every render): the largest
index whose row-bottom is still <= scrollTop, and the last index whose
row-top is < scrollTop + viewportHeight. Clamp start -= overscan / end +=
overscan against [0, items.length - 1].
- Container size: a ResizeObserver on the scroll container drives a
viewportHeight state (initial guess = the numeric height prop, or 400 for a
string height like "60vh") — corrects itself once mounted, disconnects on
unmount.
- Variable-height correction: only when estimateSize is a function, a second
ResizeObserver (shared across rows) observes every currently mounted row.
Create it lazily inside the row ref callback (`observerRef.current ??= new
ResizeObserver(...)`), never in an effect: refs attach during the commit,
before effects run, so an effect-created observer misses the whole first
screen. Every row shares ONE stable ref callback (useCallback) — an inline
arrow is a new function each render, so React would detach and re-attach
every row's ref on each scroll frame. observe() on attach, unobserve() in
the callback ref's cleanup (React 19 supports returning a cleanup from a
callback ref); disconnect in an unmount-only effect. Each row carries
data-index, and the observer maps it back to the row key through a ref
holding the last committed layout. Its callback batches all entries into
one measurements Map update (functional setState, skip if within 0.5px of
the cached value, shrinks accepted) so offsets for every row below a
newly-measured one shift to the corrected position. Fixed-size mode
(estimateSize as a number) never attaches this observer — there is nothing
to correct.
- Measurements survive data updates: they are keyed by getKey (or index), so
a new `items` reference (an appended page, an insert) keeps them. With the
adjust-state-during-render pattern — compare items against a `prevItems`
state during render, not inside a useEffect — drop only the keys that are
no longer in the dataset. Clearing the whole map would collapse an
infinite feed back to estimates on every page load and throw the reader
far from where they were; a surviving key whose content changed is simply
re-measured once its row is mounted.
- Scroll anchoring: keep an anchor ref { key, index, delta, rows } — the
first visible row (same binary search as start) and scrollTop minus its
top, plus the layout it was measured against. Capture it in the scroll
handler, after every imperative jump, and at the end of a layout effect
keyed on the memoized layout. When that effect sees a new layout, find the
anchor row again (same index if its key still matches, else search by key;
skip if it is gone), and if its top moved, set scrollTop = row.top + delta
and setState the new scrollTop so the window re-renders before paint. Rows
measured above the viewport, an appended page and (with stable keys) rows
inserted above no longer move what the user is reading, and scrollToIndex
stays on target after the overscan rows above it are measured.
- onEndReached fires once per approach to the bottom: a ref (not state) flags
"already fired" the moment distanceToBottom <= endThreshold, and only
clears once distanceToBottom exceeds it again — read and write this ref
in the scroll handler and in effects, never during render. The scroll
handler alone is not enough: a first page shorter than the viewport never
scrolls, and an appended page can leave the user inside the threshold. So
an effect keyed on [row count, total height, viewport height] runs the same
check, a changed row count re-arms the flag first, and the callback is
read through a latest-ref refreshed in a useLayoutEffect, so it is current
before that passive check runs (a stale handler would still see the
consumer's in-flight flag as true and bail). A short page therefore keeps
loading until the viewport fills — the consumer guards in-flight requests.
- onRangeChange is a side effect notifying an external callback of a derived
value ({start, end} — the actual rendered window including overscan) — call
it from a useEffect keyed on [start, end], which is the legitimate
"synchronize with an external system" use of an effect, not local
setState-in-effect.
- Imperative handle: scrollToIndex clamps the index into range, looks up its
row's top/size from the memoized layout, and sets
containerRef.current.scrollTop directly — align "start" uses row.top as-is,
"center" offsets by (viewportHeight - row.size) / 2, clamped to [0, total -
viewportHeight]. scrollToOffset just clamps to >= 0 and assigns scrollTop
(the browser clamps the upper bound itself). Both are real DOM jumps (no
animation) — the resulting native scroll event is what drives the next
window recompute. Both re-capture the scroll anchor at the new position
right away, so a measurement landing before that scroll event cannot
restore the pre-jump anchor and undo the jump.
- Empty items render `emptyState` (or a plain fallback string) instead of the
row window.
Rendering & styling
- Semantic tokens only: bg-card, border, text-muted-foreground for the
container and empty state; bg-primary/10 + text-primary for any accent
chips inside consumer row content. cn() merges the consumer's className
onto the scroll container; className/style/onScroll are destructured out of
the spread so the consumer's onScroll composes with the internal handler
instead of replacing it.
- Rows are `position: absolute; top; left: 0; right: 0` inside a spacer div
sized to the total layout height (`position: relative`); the container
itself sets `overflow-anchor: none` so the browser's native scroll-anchoring
never fights the component's own anchor correction.
- Accessibility: the scroll container is `role="list"`, `tabIndex={0}` (plain
keyboard scrolling — no custom arrow-key handling needed), `aria-label`
from the `label` prop, and `focus-visible:ring-2 ring-ring ring-inset`. Each
rendered row is `role="listitem"` with `aria-setsize={items.length}` and
`aria-posinset={index + 1}` — this is what keeps the row's position honest
to assistive tech even though only a handful of its siblings exist in the
DOM at any moment. Be upfront in docs that a screen reader still only ever
hears the rows that are currently mounted; it cannot "see" the full list at
once the way sighted scrolling does.
- No decorative motion in this component (scrollToIndex/scrollToOffset are
instant jumps, not animated), so prefers-reduced-motion has nothing to gate.
Customization levers
- overscan: raise it (8–12) for very fast scroll wheels/trackpads or heavy
row content that needs a head start rendering; lower it (0–2) for lighter
memory/DOM footprint when scroll speed is modest.
- estimateSize: pass a number when every row is truly uniform (cheapest,
perfectly stable); pass a function returning your best guess when rows
vary — the closer the guess, the less the layout jumps before measurement
settles.
- gap + row styling: gap turns a dense list into a card feed — pair it with
rounded-lg border bg-card p-4 row content for a notification/feed look, or
leave gap=0 with border-b rows for a dense table-like list.
- height: fixed px for a bounded panel, or a string ("60vh", "100%") to fill
a flexible layout — the internal ResizeObserver keeps the row window
correct either way.
- endThreshold: shrink it for a "top up right at the edge" feel, grow it to
start prefetching the next page well before the user hits bottom.
- getKey: supply it whenever items can be inserted/removed/reordered mid-list
(not just appended) — it keeps measurement and position pinned to the
right row instead of drifting with array index, and lets the scroll anchor
follow the row the user is reading when rows are prepended above it.Concepts
- Windowing — only rows within
[start - overscan, end + overscan]are ever mounted; everything else is just a number inside a prefix-sum array, never a DOM node. - Overscan — extra rows rendered outside the viewport so fast scrolling, and the ResizeObserver measuring upcoming variable-height rows, both get a head start before those rows become visible.
- Estimate → measure → correct — variable-height rows start from a guess; once a row mounts, a shared ResizeObserver reports its real height into a cache, and every row below it shifts to the corrected offset.
- Keyed measurement cache — measured heights are stored by row key, so a new
itemsarray (an appended page, an insert) keeps every height it still applies to; only keys that left the dataset are dropped, in the same render pass instead of through an effect. - Scroll anchoring — the first visible row and the viewport's offset into it are remembered; whenever the layout changes, scrollTop is moved so that row stays exactly where it was, so rows measured above the viewport or pages loaded around it never shove the content the user is reading.
- Touch-bottom-once — a ref (not state) arms the moment scroll enters
endThresholdof the bottom, firesonEndReachedexactly once, and only re-arms after the user scrolls back out of that zone or the row count changes; the check also runs when the list grows, so a page too short to scroll still loads the next one. - Imperative jump, declarative recompute —
scrollToIndex/scrollToOffsetsetscrollTopdirectly on the DOM node; the nativescrollevent that follows is what drives the next window recompute, so there's no separate sync path to keep correct.