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…

"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").
- 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 items array (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 endThreshold of the bottom, fires onEndReached exactly once, and only re-arms after the user scrolls back out of that zone.
  • Imperative jump, declarative recomputescrollToIndex/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