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…
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").
- 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)
?? (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 (created once, shared across rows) observes every currently
mounted row via its ref callback — observe() on mount, unobserve() in the
callback ref's cleanup function (React 19 supports returning a cleanup from
a callback ref). Its callback batches all entries into one measurements Map
update (functional setState, skip if within 0.5px of the cached value) 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.
- Measurement cache reset: when the `items` array reference changes (a new
dataset, not a mutation), clear the measurements Map. Do this with the
adjust-state-during-render pattern — compare items against a `prevItems`
state during render and call both setState calls right there, not inside a
useEffect — so a brand-new dataset doesn't inherit stale heights from a
different one.
- 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
only inside the scroll handler, never during render.
- 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, so no extra sync code is needed.
- 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 virtualization's own repositioning.
- 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.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.
- Adjust-state-during-render cache reset — a new
itemsarray (not a mutated one) means new content, so the measurement cache clears in the same render pass instead of through an effect. - 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. - 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.