{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "virtual-list",
  "type": "registry:ui",
  "title": "Virtual list",
  "description": "A windowed list that renders only the rows in view, with fixed or measured row heights, overscan, keyboard row navigation and scroll to index.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/virtual-list.tsx",
      "type": "registry:ui",
      "target": "components/ui/virtual-list.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { CornerDownRightIcon } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/** A windowed list that renders only the rows in view, with fixed or measured row heights, overscan, keyboard row navigation and scroll to index. */\ntype VirtualListHandle = {\n  /** Scroll until the row is in view. \"auto\" scrolls the least amount needed. */\n  scrollToIndex: (index: number, align?: \"auto\" | \"start\" | \"center\" | \"end\") => void\n  /** Move the active row and scroll to it. */\n  setActiveIndex: (index: number) => void\n  /** The scrolling element. */\n  element: HTMLDivElement | null\n}\n\ntype Invoice = {\n  id: string\n  number: string\n  customer: string\n  amount: number\n  status: \"paid\" | \"open\" | \"overdue\"\n  date: string\n}\n\ntype VirtualListProps<T> = {\n  /** Rows to render. Defaults to 10,000 Lumen invoices. */\n  items?: T[]\n  /** Row height in px, or \"auto\" to measure every row (estimatedRowHeight is used until a row has been measured). */\n  rowHeight?: number | \"auto\"\n  /** Height assumed for rows that have not been measured yet, in \"auto\" mode. */\n  estimatedRowHeight?: number\n  /** Extra rows rendered above and below the viewport so fast scrolling never shows a gap. */\n  overscan?: number\n  /** Viewport height in px. */\n  height?: number\n  /** Renders one row. Defaults to an invoice row. */\n  renderRow?: (item: T, index: number, state: { active: boolean }) => React.ReactNode\n  /** Stable key per row. Defaults to item.id, then the index. */\n  getKey?: (item: T, index: number) => React.Key\n  /** Plain text for the screen reader announcement of the active row. */\n  describeRow?: (item: T, index: number) => string\n  /** Controlled active row. */\n  activeIndex?: number\n  defaultActiveIndex?: number\n  onActiveChange?: (index: number) => void\n  /** Called on click, Enter or Space on a row. */\n  onSelect?: (item: T, index: number) => void\n  /** Header title. Pass an empty string to hide the header. */\n  title?: string\n  /** Show the \"Jump to row\" field in the header. */\n  showJump?: boolean\n  /** Show the \"Rows a to b of n\" footer. */\n  showFooter?: boolean\n  /** Imperative handle with scrollToIndex and setActiveIndex. */\n  ref?: React.Ref<VirtualListHandle>\n  \"aria-label\"?: string\n  className?: string\n}\n\nconst CUSTOMERS = [\n  \"Northwind\", \"Acme Robotics\", \"Umbrella Holdings\", \"Initech\", \"Globex\", \"Stark Industrial\", \"Wayne Freight\", \"Hooli\",\n  \"Vandelay Imports\", \"Pied Piper\", \"Aperture Labs\", \"Dunder Mifflin\", \"Sterling Cooper\", \"Bluth Co\", \"Oscorp\", \"Tyrell Corp\",\n  \"Massive Dynamic\", \"Cyberdyne\", \"Wonka Industries\", \"Monarch Solutions\", \"Prestige Worldwide\", \"Gekko & Co\", \"Soylent\", \"Virtucon\",\n]\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"]\n\n/** Deterministic sample so the server and the client render the same rows. */\nfunction makeInvoices(count: number): Invoice[] {\n  let seed = 7\n  const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296\n  const base = Date.UTC(2026, 9, 14)\n  const out: Invoice[] = []\n  for (let i = 0; i < count; i++) {\n    const r = rnd()\n    const d = new Date(base - Math.floor(i / 40) * 86400000)\n    out.push({\n      id: `inv-${count - i}`,\n      number: `INV-${String(count - i).padStart(5, \"0\")}`,\n      customer: CUSTOMERS[Math.floor(rnd() * CUSTOMERS.length)],\n      amount: Math.round((120 + rnd() * 23880) * 100) / 100,\n      status: r < 0.7 ? \"paid\" : r < 0.92 ? \"open\" : \"overdue\",\n      date: `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`,\n    })\n  }\n  return out\n}\n\nconst SAMPLE_INVOICES = makeInvoices(10000)\nconst usd = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\" })\nconst STATUS = {\n  paid: { label: \"Paid\", tone: \"text-success\" },\n  open: { label: \"Open\", tone: \"text-info\" },\n  overdue: { label: \"Overdue\", tone: \"text-danger\" },\n} as const\n\nfunction InvoiceRow({ item, active }: { item: Invoice; active: boolean }) {\n  const s = STATUS[item.status]\n  return (\n    <div\n      data-slot=\"virtual-list-row\"\n      className={cn(\n        \"relative flex h-10 items-center gap-2 border-b border-border px-3 text-[0.8125rem] transition-colors duration-[70ms] ease-hairline sm:gap-3\",\n        active ? \"bg-accent-soft\" : \"hover:bg-sunken\"\n      )}\n    >\n      {active ? <span aria-hidden className=\"absolute top-2 bottom-2 left-0 w-0.5 rounded-full bg-accent\" /> : null}\n      <span className=\"w-18 shrink-0 font-mono text-xs text-fg-muted sm:w-20\">{item.number}</span>\n      <span className=\"min-w-0 flex-1 truncate text-fg\">{item.customer}</span>\n      <span className={cn(\"hidden w-20 shrink-0 items-center gap-1.5 text-xs sm:inline-flex\", s.tone)}>\n        <span aria-hidden className=\"size-1.5 rounded-full bg-current\" />\n        {s.label}\n      </span>\n      <span className=\"w-18 shrink-0 text-right text-fg tabular-nums sm:w-22\">{usd.format(item.amount)}</span>\n      <span className=\"hidden w-12 shrink-0 text-right text-xs text-fg-subtle tabular-nums sm:block\">{item.date}</span>\n    </div>\n  )\n}\n\nconst defaultRender = (item: unknown, _index: number, state: { active: boolean }) => <InvoiceRow item={item as Invoice} active={state.active} />\nconst defaultKey = (item: unknown, index: number) => (item as { id?: React.Key } | null)?.id ?? index\nconst defaultDescribe = (item: unknown, index: number) => {\n  const inv = item as Partial<Invoice> | null\n  if (inv && inv.number && inv.customer) return `${inv.number}, ${inv.customer}, ${STATUS[inv.status ?? \"open\"].label}, ${usd.format(inv.amount ?? 0)}`\n  return `Row ${index + 1}`\n}\nconst clampIndex = (i: number, count: number) => Math.max(0, Math.min(count - 1, Math.round(i)))\n\n/**\n * A windowed list. Only the rows inside the viewport (plus an overscan band)\n * exist in the DOM, so ten thousand rows scroll like ten. Row heights are\n * fixed or measured per row, arrow keys move an active row with Home, End\n * and Page Up/Down, and a ref exposes scrollToIndex for deep links.\n */\nfunction VirtualList<T = Invoice>({\n  items = SAMPLE_INVOICES as unknown as T[],\n  rowHeight = 40,\n  estimatedRowHeight = 40,\n  overscan = 6,\n  height = 360,\n  renderRow = defaultRender,\n  getKey = defaultKey,\n  describeRow = defaultDescribe,\n  activeIndex: activeProp,\n  defaultActiveIndex = 0,\n  onActiveChange,\n  onSelect,\n  title = \"Invoices\",\n  showJump = true,\n  showFooter = true,\n  ref,\n  \"aria-label\": ariaLabel,\n  className,\n}: VirtualListProps<T>) {\n  const count = items.length\n  const auto = rowHeight === \"auto\"\n  const fixed = auto ? estimatedRowHeight : rowHeight\n  const scrollRef = React.useRef<HTMLDivElement>(null)\n  const sizes = React.useRef(new Map<number, number>())\n  const [tick, bump] = React.useReducer((n: number) => n + 1, 0)\n  const [scrollTop, setScrollTop] = React.useState(0)\n  const [viewport, setViewport] = React.useState(height)\n  const [innerActive, setInnerActive] = React.useState(() => clampIndex(defaultActiveIndex, count))\n  const active = clampIndex(activeProp ?? innerActive, count)\n  const [jump, setJump] = React.useState(\"\")\n  const pending = React.useRef<{ index: number; align: \"auto\" | \"start\" | \"center\" | \"end\"; tries: number } | null>(null)\n  const lastSet = React.useRef(0)\n  const id = React.useId()\n\n  // Prefix sums for measured rows. Fixed mode needs no table.\n  const offsets = React.useMemo(() => {\n    if (!auto) return null\n    const o = new Float64Array(count + 1)\n    for (let i = 0; i < count; i++) o[i + 1] = o[i] + (sizes.current.get(i) ?? estimatedRowHeight)\n    return o\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [auto, count, estimatedRowHeight, tick])\n\n  const offsetOf = (i: number) => (offsets ? offsets[i] : i * fixed)\n  const heightOf = (i: number) => (offsets ? offsets[i + 1] - offsets[i] : fixed)\n  const total = offsets ? offsets[count] : count * fixed\n\n  const indexAt = (y: number) => {\n    if (!offsets) return Math.min(count - 1, Math.max(0, Math.floor(y / fixed)))\n    let lo = 0\n    let hi = count - 1\n    while (lo < hi) {\n      const mid = (lo + hi + 1) >> 1\n      if (offsets[mid] <= y) lo = mid\n      else hi = mid - 1\n    }\n    return lo\n  }\n\n  const first = count ? indexAt(scrollTop) : 0\n  const last = count ? indexAt(scrollTop + viewport) : -1\n  const start = Math.max(0, first - overscan)\n  const end = Math.min(count - 1, last + overscan)\n\n  React.useEffect(() => {\n    const el = scrollRef.current\n    if (!el || typeof ResizeObserver === \"undefined\") return\n    const ro = new ResizeObserver(([e]) => setViewport(e.contentRect.height))\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [])\n\n  // One observer measures every mounted row in \"auto\" mode.\n  const measurer = React.useMemo(() => {\n    if (!auto || typeof ResizeObserver === \"undefined\") return null\n    return new ResizeObserver((entries) => {\n      let changed = false\n      for (const e of entries) {\n        const i = Number((e.target as HTMLElement).dataset.index)\n        const h = Math.round(e.contentRect.height * 100) / 100\n        if (!Number.isNaN(i) && h > 0 && sizes.current.get(i) !== h) {\n          sizes.current.set(i, h)\n          changed = true\n        }\n      }\n      if (changed) bump()\n    })\n  }, [auto])\n  React.useEffect(() => () => measurer?.disconnect(), [measurer])\n\n  const applyScroll = React.useCallback(\n    (index: number, align: \"auto\" | \"start\" | \"center\" | \"end\") => {\n      const el = scrollRef.current\n      if (!el || !count) return\n      const i = clampIndex(index, count)\n      const top = offsetOf(i)\n      const h = heightOf(i)\n      const vh = el.clientHeight\n      const cur = el.scrollTop\n      let next = cur\n      if (align === \"start\") next = top\n      else if (align === \"end\") next = top + h - vh\n      else if (align === \"center\") next = top + h / 2 - vh / 2\n      else if (top < cur) next = top\n      else if (top + h > cur + vh) next = top + h - vh\n      el.scrollTop = Math.max(0, Math.min(next, total - vh))\n      lastSet.current = el.scrollTop\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [count, total, offsets, fixed]\n  )\n\n  const scrollToIndex = React.useCallback(\n    (index: number, align: \"auto\" | \"start\" | \"center\" | \"end\" = \"auto\") => {\n      applyScroll(index, align)\n      // Measured rows can shift the target once they mount: settle it over a few ticks.\n      if (auto) pending.current = { index: clampIndex(index, count), align, tries: 0 }\n    },\n    [applyScroll, auto, count]\n  )\n\n  React.useLayoutEffect(() => {\n    const p = pending.current\n    if (!p || !auto) return\n    if (p.tries >= 4) {\n      pending.current = null\n      return\n    }\n    p.tries += 1\n    applyScroll(p.index, p.align)\n  }, [tick, auto, applyScroll])\n\n  const setActive = React.useCallback(\n    (i: number) => {\n      const n = clampIndex(i, count)\n      if (activeProp === undefined) setInnerActive(n)\n      onActiveChange?.(n)\n      scrollToIndex(n, \"auto\")\n    },\n    [activeProp, count, onActiveChange, scrollToIndex]\n  )\n\n  React.useImperativeHandle(\n    ref,\n    () => ({\n      scrollToIndex,\n      setActiveIndex: setActive,\n      get element() {\n        return scrollRef.current\n      },\n    }),\n    [scrollToIndex, setActive]\n  )\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (!count) return\n    const page = Math.max(1, Math.floor(viewport / (auto ? heightOf(active) || estimatedRowHeight : fixed)) - 1)\n    const map: Record<string, number | undefined> = {\n      ArrowDown: active + 1,\n      ArrowUp: active - 1,\n      PageDown: active + page,\n      PageUp: active - page,\n      Home: 0,\n      End: count - 1,\n    }\n    const to = map[e.key]\n    if (to !== undefined) {\n      e.preventDefault()\n      setActive(to)\n    } else if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault()\n      onSelect?.(items[active], active)\n    }\n  }\n\n  const submitJump = (e: React.FormEvent) => {\n    e.preventDefault()\n    const n = parseInt(jump, 10)\n    if (Number.isNaN(n)) return\n    const i = clampIndex(n - 1, count)\n    setActive(i)\n    scrollToIndex(i, \"start\")\n    scrollRef.current?.focus()\n  }\n\n  const rows: React.ReactNode[] = []\n  for (let i = start; i <= end; i++) {\n    const item = items[i]\n    const isActive = i === active\n    rows.push(\n      <div\n        key={getKey(item, i)}\n        role=\"listitem\"\n        aria-setsize={count}\n        aria-posinset={i + 1}\n        aria-current={isActive ? \"true\" : undefined}\n        data-index={i}\n        data-active={isActive || undefined}\n        ref={(el) => {\n          if (!measurer || !el) return\n          measurer.observe(el)\n          return () => measurer.unobserve(el)\n        }}\n        onClick={() => {\n          setActive(i)\n          onSelect?.(item, i)\n        }}\n        style={{ position: \"absolute\", top: 0, left: 0, right: 0, transform: `translateY(${offsetOf(i)}px)`, height: auto ? undefined : fixed }}\n      >\n        {renderRow(item, i, { active: isActive })}\n      </div>\n    )\n  }\n\n  const fmt = (n: number) => n.toLocaleString(\"en-US\")\n  const rendered = count ? end - start + 1 : 0\n\n  return (\n    <div data-slot=\"virtual-list\" className={cn(\"flex w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface shadow-raised\", className)}>\n      {title || showJump ? (\n        <div className=\"flex min-h-12 flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-border px-3 py-2\">\n          <div className=\"flex items-baseline gap-2\">\n            {title ? <h3 className=\"text-[0.8125rem] font-medium text-fg\">{title}</h3> : null}\n            <span className=\"text-xs text-fg-muted tabular-nums\">{fmt(count)} rows</span>\n          </div>\n          {showJump ? (\n            <form onSubmit={submitJump} className=\"flex items-center gap-1.5\">\n              <label htmlFor={`${id}-jump`} className=\"sr-only\">\n                Jump to row\n              </label>\n              <input\n                id={`${id}-jump`}\n                type=\"number\"\n                inputMode=\"numeric\"\n                min={1}\n                max={count || 1}\n                value={jump}\n                placeholder=\"Jump to row\"\n                onChange={(e) => setJump(e.target.value)}\n                className={cn(\n                  \"h-8 w-32 rounded-lg border border-border bg-bg px-2.5 text-[0.8125rem] text-fg shadow-xs outline-none placeholder:text-fg-subtle tabular-nums\",\n                  \"transition-[border-color,box-shadow] duration-[140ms] ease-hairline hover:border-border-strong\",\n                  \"focus-visible:border-accent focus-visible:shadow-[0_0_0_3px_var(--accent-soft)]\",\n                  \"[&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\"\n                )}\n              />\n              <button\n                type=\"submit\"\n                aria-label=\"Go to row\"\n                className=\"grid size-8 shrink-0 place-items-center rounded-lg border border-border bg-surface text-fg-muted shadow-key outline-none transition-[color,border-color] duration-[70ms] hover:border-border-strong hover:text-fg focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface active:translate-y-[0.5px]\"\n              >\n                <CornerDownRightIcon className=\"size-3.5\" />\n              </button>\n            </form>\n          ) : null}\n        </div>\n      ) : null}\n\n      <div\n        ref={scrollRef}\n        role=\"list\"\n        tabIndex={0}\n        aria-label={ariaLabel ?? title ?? \"List\"}\n        aria-describedby={`${id}-hint`}\n        onScroll={(e) => {\n          const top = e.currentTarget.scrollTop\n          // A scroll the user started cancels any pending settle.\n          if (Math.abs(top - lastSet.current) > 1) pending.current = null\n          setScrollTop(top)\n        }}\n        onKeyDown={onKeyDown}\n        className=\"relative overflow-y-auto overscroll-contain outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset\"\n        style={{ height }}\n      >\n        {count ? (\n          <div style={{ height: total, position: \"relative\" }}>{rows}</div>\n        ) : (\n          <p className=\"grid h-full place-items-center text-[0.8125rem] text-fg-subtle\">No rows</p>\n        )}\n      </div>\n      <span id={`${id}-hint`} className=\"sr-only\">\n        Use the arrow keys to move between rows, Page Up and Page Down to move a screen, Home and End for the first and last row.\n      </span>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {count ? `Row ${fmt(active + 1)} of ${fmt(count)}: ${describeRow(items[active], active)}` : \"\"}\n      </span>\n\n      {showFooter ? (\n        <div className=\"flex h-9 items-center justify-between gap-3 border-t border-border bg-bg px-3 font-mono text-[11px] text-fg-subtle tabular-nums\">\n          <span>\n            {count ? `Rows ${fmt(first + 1)} to ${fmt(Math.max(first + 1, last + 1))} of ${fmt(count)}` : \"Empty\"}\n          </span>\n          <span>{rendered} in the DOM</span>\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\nexport { VirtualList }\nexport type { VirtualListProps, VirtualListHandle, Invoice as VirtualListInvoice }\n"
    }
  ]
}
