{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "virtual-list",
  "type": "registry:ui",
  "title": "Virtual List",
  "description": "A windowed listbox for 10,000 rows with measured heights and sticky group headers.",
  "categories": [
    "scroll"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/virtual-list.tsx",
      "content": "\"use client\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\ntype Key = string | number;\n\ntype Row<T> = { kind: \"header\"; group: number; label: string; count: number } | { kind: \"item\"; item: T; index: number; group: number; inGroup: number; key: Key };\n\ntype Layout<T> = {\n  rows: Row<T>[];\n  /** offsets[i] is the top of row i; offsets[rows.length] is the total height. */\n  offsets: Float64Array;\n  /** Row index of each item, by item index. */\n  rowOf: Int32Array;\n  /** Row indexes of the group headers, in order. */\n  headers: number[];\n};\n\nexport type VirtualListItemState = { index: number; active: boolean; selected: boolean };\n\nexport type VirtualListProps<T> = Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\" | \"onChange\"> & {\n  items: readonly T[];\n  renderItem: (item: T, state: VirtualListItemState) => React.ReactNode;\n  /** Stable key per item. Defaults to its index. */\n  getKey?: (item: T, index: number) => Key;\n  /** Fixed row height in px. Leave it out to measure rows as they render. */\n  itemHeight?: number;\n  /** Starting guess for measured rows. Closer guesses mean a steadier scrollbar. */\n  estimateHeight?: number;\n  /** Rows rendered beyond each edge of the viewport. */\n  overscan?: number;\n  /** Groups consecutive items under a sticky header. Items must already be sorted by group. */\n  groupBy?: (item: T) => string;\n  headerHeight?: number;\n  renderGroupHeader?: (label: string, count: number) => React.ReactNode;\n  /** Text for type-to-jump. Defaults to the item itself when items are strings. */\n  getTextValue?: (item: T) => string;\n  value?: Key | null;\n  defaultValue?: Key | null;\n  onValueChange?: (key: Key, item: T) => void;\n  /** Fires as the active (keyboard) row changes. */\n  onActiveChange?: (index: number, item: T) => void;\n  /** Fires when the first and last visible items change. */\n  onRangeChange?: (first: number, last: number) => void;\n  /** Shown in place of the rows when items is empty. */\n  empty?: React.ReactNode;\n};\n\nfunction useLayout<T>(items: readonly T[], getKey: (item: T, i: number) => Key, groupBy: ((item: T) => string) | undefined, size: (row: Row<T>) => number): Layout<T> {\n  const rows = useMemo(() => {\n    const out: Row<T>[] = [];\n    let group = -1;\n    let label: string | null = null;\n    let header: Extract<Row<T>, { kind: \"header\" }> | null = null;\n    let inGroup = 0;\n    items.forEach((item, index) => {\n      if (groupBy) {\n        const next = groupBy(item);\n        if (next !== label) {\n          label = next;\n          group++;\n          inGroup = 0;\n          header = { kind: \"header\", group, label: next, count: 0 };\n          out.push(header);\n        }\n        header!.count++;\n      }\n      out.push({ kind: \"item\", item, index, group: Math.max(group, 0), inGroup: inGroup++, key: getKey(item, index) });\n    });\n    return out;\n  }, [items, getKey, groupBy]);\n\n  return useMemo(() => {\n    const offsets = new Float64Array(rows.length + 1);\n    const rowOf = new Int32Array(items.length);\n    const headers: number[] = [];\n    for (let i = 0; i < rows.length; i++) {\n      const r = rows[i];\n      if (r.kind === \"header\") headers.push(i);\n      else rowOf[r.index] = i;\n      offsets[i + 1] = offsets[i] + size(r);\n    }\n    return { rows, offsets, rowOf, headers };\n  }, [rows, items.length, size]);\n}\n\n/** First row whose bottom edge is below y. */\nfunction rowAt(offsets: Float64Array, count: number, y: number) {\n  let lo = 0;\n  let hi = count - 1;\n  while (lo < hi) {\n    const mid = (lo + hi) >> 1;\n    if (offsets[mid + 1] <= y) lo = mid + 1;\n    else hi = mid;\n  }\n  return Math.max(0, lo);\n}\n\nexport function VirtualList<T>({\n  items,\n  renderItem,\n  getKey = defaultKey,\n  itemHeight,\n  estimateHeight = 40,\n  overscan = 6,\n  groupBy,\n  headerHeight = 28,\n  renderGroupHeader = defaultHeader,\n  getTextValue,\n  value,\n  defaultValue = null,\n  onValueChange,\n  onActiveChange,\n  onRangeChange,\n  empty,\n  className,\n  onKeyDown,\n  onFocus,\n  ...rest\n}: VirtualListProps<T>) {\n  const reduce = useReducedMotion();\n  const id = useId();\n  const scroller = useRef<HTMLDivElement>(null);\n  const sticky = useRef<HTMLDivElement>(null);\n  // A row reached by keyboard stays in view while the rows around it are\n  // measured, until anything else scrolls the list.\n  const pinned = useRef<number | null>(null);\n  const expected = useRef<number | null>(null);\n  const measuring = itemHeight === undefined;\n  const grouped = !!groupBy;\n  const stickyHeight = grouped ? headerHeight : 0;\n\n  // Measured heights by key, so a row keeps its size when the list is filtered or sorted.\n  const [measured, setMeasured] = useState<ReadonlyMap<Key, number>>(() => new Map());\n  const size = useCallback(\n    (row: Row<T>) => (row.kind === \"header\" ? headerHeight : (itemHeight ?? measured.get(row.key) ?? estimateHeight)),\n    [headerHeight, itemHeight, measured, estimateHeight],\n  );\n  const layout = useLayout(items, getKey, groupBy, size);\n  const total = layout.offsets[layout.rows.length];\n\n  const [range, setRange] = useState({ start: 0, end: Math.min(layout.rows.length, 24) });\n  const [current, setCurrent] = useState(0);\n  const [active, setActive] = useState(-1);\n  const [innerValue, setInnerValue] = useState<Key | null>(defaultValue);\n  const selectedKey = value !== undefined ? value : innerValue;\n\n  // Handlers run outside render; they read the latest layout from here.\n  const live = useRef({ layout, active, stickyHeight, overscan });\n  useEffect(() => {\n    live.current = { layout, active, stickyHeight, overscan };\n  });\n  const cb = useRef({ onActiveChange, onRangeChange });\n  useEffect(() => {\n    cb.current = { onActiveChange, onRangeChange };\n  });\n\n  // Reads the scroll position once per frame. React only re-renders when the\n  // window of rows or the current group actually changes.\n  const sync = useCallback(() => {\n    const el = scroller.current;\n    if (!el) return;\n    const { layout: l, overscan: o, stickyHeight: sh } = live.current;\n    const n = l.rows.length;\n    if (!n) return;\n    const top = el.scrollTop;\n    const first = rowAt(l.offsets, n, top);\n    const last = rowAt(l.offsets, n, top + el.clientHeight - 1);\n    const start = Math.max(0, first - o);\n    const end = Math.min(n, last + 1 + o);\n    setRange((r) => (r.start === start && r.end === end ? r : { start, end }));\n\n    const firstItem = l.rows.slice(first, last + 1).find((r) => r.kind === \"item\");\n    const lastItem = l.rows.slice(first, last + 1).findLast((r) => r.kind === \"item\");\n    if (firstItem?.kind === \"item\" && lastItem?.kind === \"item\") cb.current.onRangeChange?.(firstItem.index, lastItem.index);\n\n    if (sh) {\n      // The group whose header has scrolled under the top edge owns the sticky slot;\n      // the next header pushes it up and out as it arrives.\n      let g = 0;\n      for (let i = 0; i < l.headers.length; i++) if (l.offsets[l.headers[i]] <= top + 0.5) g = i;\n      setCurrent(g);\n      const next = l.headers[g + 1];\n      const push = next === undefined ? 0 : Math.min(0, l.offsets[next] - top - sh);\n      if (sticky.current) sticky.current.style.transform = push ? `translateY(${push}px)` : \"\";\n    }\n  }, []);\n\n  useEffect(() => {\n    const el = scroller.current;\n    if (!el) return;\n    let frame = 0;\n    const onScroll = () => {\n      // A scroll this list didn't make (wheel, scrollbar, find-in-page) hands control back.\n      if (expected.current !== null && Math.abs(el.scrollTop - expected.current) > 1) pinned.current = null;\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(sync);\n    };\n    const release = () => (pinned.current = null);\n    const ro = new ResizeObserver(onScroll);\n    ro.observe(el);\n    el.addEventListener(\"scroll\", onScroll, { passive: true });\n    el.addEventListener(\"pointerdown\", release);\n    return () => {\n      cancelAnimationFrame(frame);\n      ro.disconnect();\n      el.removeEventListener(\"scroll\", onScroll);\n      el.removeEventListener(\"pointerdown\", release);\n    };\n  }, [sync]);\n\n  // Re-window whenever the layout changes (new items, new measurements).\n  useEffect(() => {\n    sync();\n  }, [layout, sync]);\n\n  // Measured mode: one observer for every rendered row. A row above the\n  // viewport that changes height moves the scroll by the same amount, so the\n  // row being read never jumps.\n  const anchor = useRef(0);\n  const observer = useRef<ResizeObserver | null>(null);\n  useEffect(() => {\n    if (!measuring) return;\n    const ro = new ResizeObserver((entries) => {\n      const el = scroller.current;\n      const { layout: l } = live.current;\n      const changes: [Key, number][] = [];\n      for (const entry of entries) {\n        const target = entry.target as HTMLElement;\n        const rowIndex = Number(target.dataset.row);\n        const row = l.rows[rowIndex];\n        if (!row || row.kind !== \"item\") continue;\n        const h = entry.borderBoxSize?.[0]?.blockSize ?? target.offsetHeight;\n        const was = l.offsets[rowIndex + 1] - l.offsets[rowIndex];\n        if (Math.abs(h - was) < 0.5) continue;\n        changes.push([row.key, h]);\n        if (el && l.offsets[rowIndex] < el.scrollTop) anchor.current += h - was;\n      }\n      if (!changes.length) return;\n      setMeasured((prev) => {\n        const next = new Map(prev);\n        for (const [k, h] of changes) next.set(k, h);\n        return next;\n      });\n    });\n    observer.current = ro;\n    // Rows that mounted before this observer existed.\n    scroller.current?.querySelectorAll(\"[role=option]\").forEach((n) => ro.observe(n));\n    return () => {\n      ro.disconnect();\n      observer.current = null;\n    };\n  }, [measuring]);\n\n  useLayoutEffect(() => {\n    const el = scroller.current;\n    if (!el) return;\n    if (anchor.current) {\n      el.scrollTop += anchor.current;\n      expected.current = el.scrollTop;\n    }\n    anchor.current = 0;\n    if (pinned.current !== null && pinned.current < items.length) reveal(layout.rowOf[pinned.current]);\n    // reveal only reads the layout it is given.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [layout]);\n\n\n\n  const measureRef = useCallback((node: HTMLDivElement | null) => {\n    if (!node || !observer.current) return;\n    observer.current.observe(node);\n    return () => observer.current?.unobserve(node);\n  }, []);\n\n  // Keyboard moves render instantly and scroll only as far as needed, keeping\n  // the row clear of the sticky header.\n  function reveal(rowIndex: number) {\n    const el = scroller.current;\n    if (!el) return;\n    const top = layout.offsets[rowIndex];\n    const bottom = layout.offsets[rowIndex + 1];\n    if (top < el.scrollTop + stickyHeight) el.scrollTop = top - stickyHeight;\n    else if (bottom > el.scrollTop + el.clientHeight) el.scrollTop = bottom - el.clientHeight;\n    expected.current = el.scrollTop;\n  }\n\n  const moveTo = (index: number) => {\n    if (!items.length) return;\n    const i = Math.max(0, Math.min(items.length - 1, index));\n    setActive(i);\n    cb.current.onActiveChange?.(i, items[i]);\n    pinned.current = i;\n    reveal(layout.rowOf[i]);\n  };\n\n  const select = (index: number) => {\n    const item = items[index];\n    if (item === undefined) return;\n    const key = getKey(item, index);\n    if (value === undefined) setInnerValue(key);\n    onValueChange?.(key, item);\n  };\n\n  // Type to jump: letters typed within half a second build one query.\n  const typed = useRef({ text: \"\", at: 0 });\n  const textOf = (item: T) => (getTextValue ? getTextValue(item) : typeof item === \"string\" ? item : \"\");\n  const typeahead = (ch: string, now: number) => {\n    const t = typed.current;\n    const fresh = now - t.at > 500;\n    t.text = fresh ? ch : t.text + ch;\n    t.at = now;\n    const q = t.text.toLowerCase();\n    // A repeated single letter cycles through matches; a longer query refines in place.\n    const from = t.text.length === 1 ? active + 1 : Math.max(0, active);\n    for (let k = 0; k < items.length; k++) {\n      const i = (from + k) % items.length;\n      if (textOf(items[i]).toLowerCase().startsWith(q)) return moveTo(i);\n    }\n  };\n\n  const handleKey = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(e);\n    if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || !items.length) return;\n    const el = scroller.current;\n    const at = active < 0 ? 0 : active;\n    const pageBy = (dir: 1 | -1) => {\n      if (!el) return at;\n      const row = layout.rowOf[at];\n      const y = layout.offsets[row] + dir * (el.clientHeight - stickyHeight);\n      let r = rowAt(layout.offsets, layout.rows.length, Math.max(0, y));\n      while (layout.rows[r]?.kind === \"header\") r += dir > 0 ? 1 : -1;\n      const target = layout.rows[r];\n      return target?.kind === \"item\" ? target.index : dir > 0 ? items.length - 1 : 0;\n    };\n    const map: Record<string, () => number> = {\n      ArrowDown: () => (active < 0 ? 0 : at + 1),\n      ArrowUp: () => (active < 0 ? 0 : at - 1),\n      PageDown: () => pageBy(1),\n      PageUp: () => pageBy(-1),\n      Home: () => 0,\n      End: () => items.length - 1,\n    };\n    if (map[e.key]) {\n      e.preventDefault();\n      moveTo(map[e.key]());\n    } else if (e.key === \"Enter\" || e.key === \" \") {\n      if (active < 0) return;\n      e.preventDefault();\n      select(active);\n    } else if (e.key.length === 1 && e.key !== \" \") {\n      // Letters belong to the list while it has focus, not to app-wide shortcuts.\n      e.preventDefault();\n      e.stopPropagation();\n      typeahead(e.key, e.timeStamp);\n    }\n  };\n\n  // The active row is always rendered, even when scrolled away, so\n  // aria-activedescendant never points at nothing.\n  const visible: number[] = [];\n  for (let r = range.start; r < Math.min(range.end, layout.rows.length); r++) visible.push(r);\n  const activeRow = active >= 0 && active < items.length ? layout.rowOf[active] : -1;\n  if (activeRow >= 0 && (activeRow < range.start || activeRow >= range.end)) visible.push(activeRow);\n\n  const optionId = (index: number) => `${id}-o${index}`;\n  const header = grouped ? layout.rows[layout.headers[current] ?? -1] : undefined;\n\n  const renderRow = (r: number) => {\n    const row = layout.rows[r];\n    const top = layout.offsets[r];\n    if (row.kind === \"header\") {\n      return (\n        <div key={`h${row.group}`} aria-hidden className=\"absolute inset-x-0 top-0\" style={{ transform: `translateY(${top}px)`, height: headerHeight }}>\n          {renderGroupHeader(row.label, row.count)}\n        </div>\n      );\n    }\n    const isActive = row.index === active;\n    const isSelected = selectedKey !== null && row.key === selectedKey;\n    return (\n      <div\n        key={row.key}\n        ref={measuring ? measureRef : undefined}\n        id={optionId(row.index)}\n        role=\"option\"\n        aria-selected={isSelected}\n        aria-posinset={(grouped ? row.inGroup : row.index) + 1}\n        aria-setsize={grouped ? (layout.rows[layout.headers[row.group]] as { count: number }).count : items.length}\n        data-row={r}\n        data-active={isActive || undefined}\n        data-selected={isSelected || undefined}\n        onPointerDown={(e) => {\n          // Keep focus on the list: the rows are descendants, not tab stops.\n          e.preventDefault();\n          scroller.current?.focus({ preventScroll: true });\n          setActive(row.index);\n          cb.current.onActiveChange?.(row.index, row.item);\n        }}\n        onClick={() => select(row.index)}\n        className={cn(\n          \"group/row absolute inset-x-0 top-0 flex cursor-default select-none items-center pr-9\",\n          \"before:pointer-events-none before:absolute before:inset-x-1 before:inset-y-px before:rounded-md before:content-[''] before:transition-colors before:duration-100\",\n          \"hover:before:bg-hover group-focus/vl:data-active:before:bg-hover active:before:bg-fg/[0.07]\",\n          // Only keyboard focus draws the ring, and only on the active row.\n          \"group-focus-visible/vl:data-active:before:outline-solid group-focus-visible/vl:data-active:before:outline-1 group-focus-visible/vl:data-active:before:-outline-offset-1 group-focus-visible/vl:data-active:before:outline-fg-4\",\n        )}\n        style={{ transform: `translateY(${top}px)`, height: itemHeight }}\n      >\n        <div className=\"relative min-w-0 flex-1\">{renderItem(row.item, { index: row.index, active: isActive, selected: isSelected })}</div>\n        {isSelected && (\n          <svg aria-hidden width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"absolute right-3.5 top-1/2 -translate-y-1/2 text-fg\">\n            <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.24, ease: ease.out }} />\n          </svg>\n        )}\n      </div>\n    );\n  };\n\n  // Rows sit inside role=\"group\" wrappers per group so screen readers hear the\n  // group name; the wrappers have no box, so positioning is unaffected.\n  const body = () => {\n    if (!grouped) return visible.map(renderRow);\n    const byGroup = new Map<number, number[]>();\n    for (const r of visible) {\n      const g = layout.rows[r].group;\n      if (!byGroup.has(g)) byGroup.set(g, []);\n      byGroup.get(g)!.push(r);\n    }\n    return [...byGroup].map(([g, rs]) => {\n      const h = layout.rows[layout.headers[g]] as { label: string };\n      return (\n        <div key={`g${g}`} role=\"group\" aria-label={h.label}>\n          {rs.map(renderRow)}\n        </div>\n      );\n    });\n  };\n\n  return (\n    <div\n      ref={scroller}\n      role=\"listbox\"\n      tabIndex={0}\n      aria-activedescendant={active >= 0 && items.length ? optionId(active) : undefined}\n      onKeyDown={handleKey}\n      onFocus={(e) => {\n        onFocus?.(e);\n        // Arriving by Tab lands on the selected row, else the first visible one.\n        if (e.target !== e.currentTarget || active >= 0 || !items.length) return;\n        const selectedIndex = selectedKey === null ? -1 : items.findIndex((it, i) => getKey(it, i) === selectedKey);\n        if (selectedIndex >= 0) return moveTo(selectedIndex);\n        const firstVisible = layout.rows.slice(range.start).find((r) => r.kind === \"item\" && layout.offsets[layout.rowOf[r.index]] >= (scroller.current?.scrollTop ?? 0) + stickyHeight - 1);\n        if (firstVisible?.kind === \"item\") setActive(firstVisible.index);\n      }}\n      className={cn(\"group/vl relative overflow-y-auto overscroll-contain outline-none [overflow-anchor:none]\", className)}\n      {...rest}\n    >\n      {items.length === 0 ? (\n        <div className=\"grid h-full min-h-32 place-items-center p-6 text-center text-[12.5px] text-fg-3\">{empty ?? \"Nothing to show\"}</div>\n      ) : (\n        <>\n          {grouped && header?.kind === \"header\" && (\n            // Zero-height and sticky: pinned to the top of the viewport without taking space.\n            <div aria-hidden className=\"pointer-events-none sticky top-0 z-(--z-sticky) h-0\">\n              <div ref={sticky} className=\"absolute inset-x-0 top-0\" style={{ height: headerHeight }}>\n                {renderGroupHeader(header.label, header.count)}\n              </div>\n            </div>\n          )}\n          <div className=\"relative w-full\" style={{ height: total }}>\n            {body()}\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n\nfunction defaultKey(_: unknown, index: number) {\n  return index;\n}\n\nfunction defaultHeader(label: string, count: number) {\n  return (\n    <div className=\"flex h-full items-center justify-between border-b border-line bg-frame pl-3.5 pr-9 font-mono text-2xs uppercase tracking-[0.08em] text-fg-3\">\n      <span className=\"truncate\">{label}</span>\n      <span className=\"tabular\" suppressHydrationWarning>\n        {count.toLocaleString()}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/virtual-list"
}
