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
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export interface VirtualListHandle {
  /** Jumps so `index` sits at the top of the viewport, or vertically centered with `align: "center"`. */
  scrollToIndex: (index: number, opts?: { align?: "start" | "center" }) => void
  /** Jumps the scroll position to an absolute pixel offset (clamped by the browser to the valid range). */
  scrollToOffset: (offset: number) => void
}

export interface VirtualListProps<T> extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  items: T[]

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/virtual-list.json

Prompt

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 items array (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 endThreshold of the bottom, fires onEndReached exactly 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/scrollToOffset set scrollTop directly on the DOM node; the native scroll event that follows is what drives the next window recompute, so there's no separate sync path to keep correct.

On This Page