{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "virtual-list",
  "title": "Virtual List",
  "description": "A long list that only puts the rows you can see into the DOM: five thousand rows render as about thirty nodes, so the page stops taking seconds to paint and scrolling stops stuttering. Reach for it on an admin table or data grid, a log, audit or event viewer, chat and message history, search results over a big local array, a file or asset browser, a select with thousands of options, or any list where you already hold every row in memory. Common asks it answers: \"virtual list react\", \"virtualized list\", \"windowing\", \"react-window alternative\", \"react-virtualized alternative\", \"TanStack Virtual without the wiring\", \"render 10000 rows react\", \"long list is slow to render\", \"list virtualization with dynamic row heights\", \"variable height virtual list\", \"scroll performance long list\", \"only render visible items\". shadcn/ui has no virtualization at all — its table renders every row you hand it — so this gets wired up by hand against TanStack Virtual or react-window each time, and the same four things break. Focus survives here: the row you tabbed into stays mounted after it scrolls out of the window, instead of being unmounted under you and dropping focus to the top of the page. Screen readers get the real position, because every row carries aria-posinset and aria-setsize — \"item 4,213 of 5,000\", not a count of the handful that happen to be mounted — and the spacer that holds the scroll height is marked presentational so the list and its items stay related. The view does not jump: rows are measured as they mount with a ResizeObserver, and when a row above the viewport turns out taller than the estimate, or older rows are prepended, the scroll offset is corrected against a row-keyed anchor in a layout effect, before the browser paints. That anchor is why prepending older chat messages keeps the message you were reading exactly where it was. And positions can be restored, via defaultScrollOffset plus a ref handle with scrollToIndex(index, \"auto\" | \"start\" | \"center\" | \"end\"), scrollToOffset and getScrollOffset. Rows may be any height and nothing has to be declared up front; estimateItemHeight (default 48) is only the guess used before a row has been measured, and overscan (default 4) sets how many rows are kept mounted beyond the edges. Controlled by count plus a render function — children is called with an index, so the data can live anywhere — with itemKey for stable identity, onScroll and empty. Defaults to role list/listitem; pass role=\"listbox\" and itemRole=\"option\" when the rows are selectable. Set the height with className (the default is h-72); rows are absolutely positioned, so give them padding rather than a vertical margin. Vertical only, and find-in-page reaches mounted rows only, which is inherent to windowing. Styled with shadcn tokens so it follows light and dark themes, and it ships with no dependencies at all — no Radix, no virtualization library.",
  "files": [
    {
      "path": "registry/ui/virtual-list.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type ScrollAlign = \"auto\" | \"start\" | \"center\" | \"end\"\n\nexport interface VirtualListHandle {\n  /** Bring a row into view. `auto` (the default) only moves if the row is off-screen. */\n  scrollToIndex: (index: number, align?: ScrollAlign) => void\n  /** Jump to a pixel offset — the other half of restoring a saved scroll position. */\n  scrollToOffset: (offset: number) => void\n  /** The current pixel offset, to save before the list unmounts. */\n  getScrollOffset: () => number\n}\n\n// useLayoutEffect measures the rows and corrects the scroll offset before paint, which is the\n// whole reason the list doesn't visibly jump; it warns during SSR — fall back to useEffect on the\n// server, where there is nothing to measure and no scroll position to hold.\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\n/**\n * The top edge of every row, plus one last entry holding the total height: `offsets[i]` is where\n * row `i` starts and `offsets[count]` is how tall the whole list is. Everything else here is\n * arithmetic on this one array.\n */\nfunction buildOffsets(count: number, heightAt: (index: number) => number): number[] {\n  const offsets = new Array<number>(count + 1)\n  offsets[0] = 0\n  for (let i = 0; i < count; i++) {\n    const height = heightAt(i)\n    offsets[i + 1] = offsets[i] + (Number.isFinite(height) && height > 0 ? height : 0)\n  }\n  return offsets\n}\n\n/**\n * The first row whose bottom edge is past `offset` — the row the viewport starts inside. Rows that\n * measured zero are skipped rather than returned, since nothing of them is on screen. Past the end\n * of the list this clamps to the last row.\n */\nfunction findRowAt(offsets: number[], offset: number): number {\n  const count = offsets.length - 1\n  if (count <= 0) return 0\n  let low = 0\n  let high = count - 1\n  while (low < high) {\n    const mid = (low + high) >> 1\n    if (offsets[mid + 1] > offset) high = mid\n    else low = mid + 1\n  }\n  return low\n}\n\n/** How many rows start above `offset` — the exclusive end of the range that has come into view. */\nfunction countRowsStartingBefore(offsets: number[], offset: number): number {\n  let low = 0\n  let high = offsets.length - 1\n  while (low < high) {\n    const mid = (low + high) >> 1\n    if (offsets[mid] >= offset) high = mid\n    else low = mid + 1\n  }\n  return low\n}\n\n/**\n * The half-open range of rows to mount. Every row touching the viewport is inside it, plus\n * `overscan` rows on each side so a scroll of a few pixels doesn't have to mount anything.\n */\nfunction computeWindow(\n  offsets: number[],\n  scrollTop: number,\n  viewportHeight: number,\n  overscan: number\n): { start: number; end: number } {\n  const count = offsets.length - 1\n  if (count <= 0) return { start: 0, end: 0 }\n  const top = Math.max(0, Math.min(scrollTop, offsets[count]))\n  const first = findRowAt(offsets, top)\n  const past = countRowsStartingBefore(offsets, top + Math.max(0, viewportHeight))\n  // A negative overscan would pull the two ends past each other and mount nothing at all — a blank\n  // list with no error, which is a miserable thing to have to diagnose from a stray minus sign.\n  const pad = Math.max(0, overscan)\n  return {\n    start: Math.max(0, first - pad),\n    // `first + 1` keeps one row mounted before the height of the box is known, which is the state\n    // of the world on the very first paint.\n    end: Math.min(count, Math.max(past, first + 1) + pad),\n  }\n}\n\n/** Where to scroll so that `index` sits at the requested edge, clamped to the scrollable range. */\nfunction offsetForIndex(\n  offsets: number[],\n  index: number,\n  viewportHeight: number,\n  align: ScrollAlign,\n  currentOffset: number\n): number {\n  const count = offsets.length - 1\n  if (count <= 0) return 0\n  // A row that isn't a real number would otherwise turn the scroll offset into NaN.\n  if (!Number.isFinite(index)) return Math.max(0, Math.min(currentOffset, offsets[count]))\n  const row = Math.max(0, Math.min(Math.trunc(index), count - 1))\n  const top = offsets[row]\n  const bottom = offsets[row + 1]\n  const limit = Math.max(0, offsets[count] - viewportHeight)\n\n  let next = currentOffset\n  if (align === \"start\") next = top\n  else if (align === \"end\") next = bottom - viewportHeight\n  else if (align === \"center\") next = top - (viewportHeight - (bottom - top)) / 2\n  else if (top < currentOffset) next = top\n  else if (bottom > currentOffset + viewportHeight) next = bottom - viewportHeight\n\n  return Math.max(0, Math.min(next, limit))\n}\n\nexport interface VirtualListProps {\n  /** How many rows the list has in total — not how many are on screen. */\n  count: number\n  /** Renders one row. Called only for the rows that are actually mounted. */\n  children: (index: number) => React.ReactNode\n  /**\n   * A stable key per row. Measurements, the focused row and the scroll anchor are all tracked by\n   * this, so passing one keeps the view still when rows are prepended (older chat messages) rather\n   * than only appended. Defaults to the index.\n   */\n  itemKey?: (index: number) => React.Key\n  /** Height to assume for rows that have not been measured yet. */\n  estimateItemHeight?: number\n  /** Extra rows mounted above and below the viewport. */\n  overscan?: number\n  /** Scroll offset to start at — the restore half of a saved position. */\n  defaultScrollOffset?: number\n  /** Called with the pixel offset on every scroll, e.g. to save it. */\n  onScroll?: (offset: number) => void\n  /** Rendered in place of the rows when `count` is 0. */\n  empty?: React.ReactNode\n  /** Applies to the scroll container. Set the height here (the default is `h-72`). */\n  className?: string\n  /** Applies to the wrapper around each row. Rows need padding rather than margin — see below. */\n  itemClassName?: string\n  /** Swap to `listbox`/`option` (or `grid`/`row`) if the rows are selectable rather than static. */\n  role?: string\n  itemRole?: string\n  /** Name the list. It is a focusable scroll region, so it should have one. */\n  \"aria-label\"?: string\n  \"aria-labelledby\"?: string\n}\n\n/**\n * A long list that only puts the visible rows in the DOM: admin tables, log and audit viewers,\n * chat history, a select with thousands of options. Give it `count` and a function that renders\n * row `i`, and thirty nodes stand in for five thousand.\n *\n * Rows may be any height and are measured as they mount, so nothing has to be declared up front.\n * Four things that windowing usually breaks are handled here:\n *\n * - **Focus survives.** The row holding focus stays mounted even after it scrolls out of the\n *   window, so tabbing or arrowing through a list doesn't dump the user back at `<body>`.\n * - **Screen readers get the real count.** Each row carries `aria-posinset`/`aria-setsize`, so it\n *   reads \"item 4,213 of 5,000\" instead of the handful that happen to be mounted.\n * - **The view doesn't jump.** Measuring a row above the viewport, or prepending rows, shifts\n *   everything below it; the scroll offset is corrected in the same frame against a row-keyed\n *   anchor, before the browser paints.\n * - **Positions can be restored**, via `defaultScrollOffset` and the imperative handle.\n *\n * Rows are positioned absolutely: give them padding or a fixed height, never a vertical margin\n * (a margin sits outside the measured box, so it would not be counted). The list scrolls\n * vertically only. Browser find-in-page and Ctrl+F reach mounted rows only — that is inherent to\n * windowing, so don't reach for this on a page whose whole point is being searchable.\n */\nexport const VirtualList = React.forwardRef<VirtualListHandle, VirtualListProps>(\n  function VirtualList(\n    {\n      count,\n      children,\n      itemKey,\n      estimateItemHeight = 48,\n      overscan = 4,\n      defaultScrollOffset = 0,\n      onScroll,\n      empty,\n      className,\n      itemClassName,\n      role = \"list\",\n      itemRole = \"listitem\",\n      \"aria-label\": ariaLabel,\n      \"aria-labelledby\": ariaLabelledby,\n    },\n    ref\n  ) {\n    const scrollerRef = React.useRef<HTMLDivElement>(null)\n    const observerRef = React.useRef<ResizeObserver | null>(null)\n    /** Measured heights by row key. A ref, not state: `measureTick` is the render signal. */\n    const heightsRef = React.useRef(new Map<string, number>())\n    const observedRef = React.useRef(new Set<Element>())\n    /** The row the viewport is sitting on, and how far into it — see `rememberAnchor`. */\n    const anchorRef = React.useRef<{ key: string; delta: number } | null>(null)\n\n    const [scrollTop, setScrollTop] = React.useState(0)\n    const [viewportHeight, setViewportHeight] = React.useState(0)\n    const [measureTick, setMeasureTick] = React.useState(0)\n    const [focusedKey, setFocusedKey] = React.useState<string | null>(null)\n\n    const { keys, offsets, indexByKey } = React.useMemo(() => {\n      const keys = new Array<string>(count)\n      const indexByKey = new Map<string, number>()\n      for (let i = 0; i < count; i++) {\n        const key = String(itemKey ? itemKey(i) : i)\n        keys[i] = key\n        indexByKey.set(key, i)\n      }\n      const heights = heightsRef.current\n      // Measurements are kept for rows that scrolled out, because they are very likely to come\n      // back. Rows that are swapped out wholesale instead — a new filter, a new query — never do,\n      // so once the cache is well ahead of the list it is dropped back to what the list can address.\n      if (heights.size > count * 2 + 256) {\n        for (const key of heights.keys()) if (!indexByKey.has(key)) heights.delete(key)\n      }\n      const offsets = buildOffsets(count, (i) => heights.get(keys[i]) ?? estimateItemHeight)\n      return { keys, offsets, indexByKey }\n      // measureTick is the signal that heightsRef changed underneath this memo.\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [count, itemKey, estimateItemHeight, measureTick])\n\n    /**\n     * Remember which row the viewport is on. Keyed by row rather than by index or by pixels,\n     * because all three of the things that move the list — a row above being measured, rows being\n     * prepended, a row being removed — change what a given pixel offset or index points at.\n     */\n    function rememberAnchor(offset: number) {\n      if (count === 0) {\n        anchorRef.current = null\n        return\n      }\n      const index = findRowAt(offsets, Math.max(0, offset))\n      anchorRef.current = { key: keys[index], delta: offset - offsets[index] }\n    }\n\n    function scrollTo(offset: number) {\n      const scroller = scrollerRef.current\n      if (!scroller) return\n      scroller.scrollTop = offset\n      // Read the offset back rather than trusting the one just written: the browser clamps to the\n      // scrollable range, so a request past either end (an over-large defaultScrollOffset, a saved\n      // position from when the list was longer) would otherwise leave the mounted window and the\n      // anchor describing a place the list is not.\n      const applied = scroller.scrollTop\n      // The browser's own scroll event lands a frame later; setting this now keeps the mounted\n      // window and the anchor in step with the position that was just written.\n      setScrollTop(applied)\n      rememberAnchor(applied)\n    }\n\n    React.useImperativeHandle(\n      ref,\n      () => ({\n        scrollToIndex(index, align = \"auto\") {\n          const scroller = scrollerRef.current\n          if (!scroller) return\n          scrollTo(offsetForIndex(offsets, index, scroller.clientHeight, align, scroller.scrollTop))\n        },\n        scrollToOffset(offset) {\n          const scroller = scrollerRef.current\n          if (!scroller) return\n          scrollTo(Math.max(0, Math.min(offset, Math.max(0, offsets[count] - scroller.clientHeight))))\n        },\n        getScrollOffset() {\n          return scrollerRef.current?.scrollTop ?? 0\n        },\n      }),\n      // scrollTo and rememberAnchor close over exactly these.\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n      [offsets, keys, count]\n    )\n\n    // One observer for the scroll box and every mounted row. Rows are watched rather than measured\n    // once, so a row that grows later (an image finishing, a details row opening) is accounted for\n    // instead of leaving a gap.\n    useIsomorphicLayoutEffect(() => {\n      const scroller = scrollerRef.current\n      if (!scroller) return\n      setViewportHeight(scroller.clientHeight)\n\n      const observer = new ResizeObserver((entries) => {\n        const heights = heightsRef.current\n        let changed = false\n        for (const entry of entries) {\n          if (entry.target === scroller) {\n            setViewportHeight(scroller.clientHeight)\n            continue\n          }\n          const key = (entry.target as HTMLElement).dataset.virtualKey\n          if (key === undefined) continue\n          const box = entry.borderBoxSize?.[0]\n          const height = box ? box.blockSize : entry.target.getBoundingClientRect().height\n          const previous = heights.get(key)\n          // Sub-pixel noise would otherwise bounce between two heights forever: each render\n          // re-measures, disagrees by a rounding error and asks for another one.\n          if (previous !== undefined && Math.abs(previous - height) < 0.5) continue\n          heights.set(key, height)\n          changed = true\n        }\n        if (changed) setMeasureTick((tick) => tick + 1)\n      })\n\n      observerRef.current = observer\n      observer.observe(scroller)\n      return () => {\n        observer.disconnect()\n        observerRef.current = null\n        observedRef.current.clear()\n      }\n    }, [])\n\n    // Runs after every render: watch the rows that just mounted, stop watching the ones that left.\n    // Re-observing an element that is already watched would fire a fresh measurement each render,\n    // so the set of watched rows is reconciled rather than rebuilt.\n    useIsomorphicLayoutEffect(() => {\n      const observer = observerRef.current\n      const scroller = scrollerRef.current\n      if (!observer || !scroller) return\n      const observed = observedRef.current\n      const rows = new Set<Element>(scroller.querySelectorAll(\"[data-virtual-key]\"))\n      for (const element of observed) {\n        if (rows.has(element)) continue\n        observer.unobserve(element)\n        observed.delete(element)\n      }\n      for (const element of rows) {\n        if (observed.has(element)) continue\n        observer.observe(element)\n        observed.add(element)\n      }\n    })\n\n    // Scroll anchoring. When the heights above the viewport change — a row was measured for real,\n    // or rows were prepended — everything below shifts by that difference and the list appears to\n    // jump under the pointer. Putting the anchor row back where it was, in a layout effect, means\n    // the correction happens before the browser paints, so there is nothing to see.\n    useIsomorphicLayoutEffect(() => {\n      const scroller = scrollerRef.current\n      const anchor = anchorRef.current\n      if (!scroller || !anchor) return\n      const index = indexByKey.get(anchor.key)\n      if (index === undefined) return\n      const limit = Math.max(0, offsets[count] - scroller.clientHeight)\n      const next = Math.max(0, Math.min(offsets[index] + anchor.delta, limit))\n      if (Math.abs(next - scroller.scrollTop) < 0.5) return\n      scroller.scrollTop = next\n      setScrollTop(next)\n    }, [offsets, indexByKey, count])\n\n    useIsomorphicLayoutEffect(() => {\n      if (!defaultScrollOffset) return\n      // Only the estimate is known this early, so a restored offset is approximate until the rows\n      // above it have been measured — at which point the anchor set here holds the view steady.\n      scrollTo(defaultScrollOffset)\n      // Mount only: this is a default, not a controlled value.\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [])\n\n    function handleScroll(event: React.UIEvent<HTMLDivElement>) {\n      const offset = event.currentTarget.scrollTop\n      setScrollTop(offset)\n      rememberAnchor(offset)\n      onScroll?.(offset)\n    }\n\n    // Focus lands on something inside a row: remember which row, so the window below keeps it\n    // mounted. Tracked by key so that prepending rows doesn't move the pin onto a different row.\n    function handleFocus(event: React.FocusEvent<HTMLDivElement>) {\n      const row = (event.target as HTMLElement).closest<HTMLElement>(\"[data-virtual-key]\")\n      setFocusedKey(row?.dataset.virtualKey ?? null)\n    }\n\n    function handleBlur(event: React.FocusEvent<HTMLDivElement>) {\n      // Moving between two rows keeps the pin — the matching focus event replaces it.\n      if (event.currentTarget.contains(event.relatedTarget)) return\n      setFocusedKey(null)\n    }\n\n    const { start, end } = computeWindow(offsets, scrollTop, viewportHeight, overscan)\n    const pinned = focusedKey === null ? undefined : indexByKey.get(focusedKey)\n    const mounted: number[] = []\n    // Kept in index order so that reading order, and the order rows are tabbed through, still\n    // match what is on screen when the pinned row sits outside the window.\n    if (pinned !== undefined && pinned < start) mounted.push(pinned)\n    for (let i = start; i < end; i++) mounted.push(i)\n    if (pinned !== undefined && pinned >= end) mounted.push(pinned)\n\n    return (\n      <div\n        ref={scrollerRef}\n        role={role}\n        // A scroll region has to be reachable by keyboard, and this one owns its own scrollbar.\n        tabIndex={0}\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledby}\n        onScroll={handleScroll}\n        onFocus={handleFocus}\n        onBlur={handleBlur}\n        style={{\n          // The browser's own scroll anchoring picks its own anchor and would pull against the\n          // correction above, which knows which row the viewport was actually on.\n          overflowAnchor: \"none\",\n          // Reserve the scrollbar's width even when it isn't showing. Without this, a list that\n          // measures out near the height of its box can flip the scrollbar on, narrowing the rows,\n          // which re-wraps their text, which changes the height that decided the scrollbar.\n          scrollbarGutter: \"stable\",\n        }}\n        className={cn(\n          \"relative h-72 overflow-y-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n          className\n        )}\n      >\n        {count === 0 ? (\n          empty\n        ) : (\n          // Holds the full height so the scrollbar reflects the whole list. Explicitly\n          // presentational: a plain div between the list and its items would break the ownership\n          // the two roles rely on, and the rows would stop being read as a list.\n          <div role=\"presentation\" style={{ position: \"relative\", height: offsets[count] }}>\n            {mounted.map((index) => (\n              <div\n                key={keys[index]}\n                data-virtual-key={keys[index]}\n                role={itemRole}\n                // The whole point of announcing position: only a window of rows exists, so without\n                // these a screen reader counts the mounted handful instead of the real list.\n                aria-setsize={count}\n                aria-posinset={index + 1}\n                style={{ position: \"absolute\", top: offsets[index], left: 0, right: 0 }}\n                className={itemClassName}\n              >\n                {children(index)}\n              </div>\n            ))}\n          </div>\n        )}\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}