{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "infinite-list",
  "type": "registry:ui",
  "title": "Infinite List",
  "description": "A scrolling feed that prefetches pages under skeletons and recovers from failures.",
  "categories": [
    "async"
  ],
  "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/infinite-list.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Children, isValidElement, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\ntype Status = \"idle\" | \"loading\" | \"error\";\n\nexport type InfiniteListProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The items loaded so far, each with a stable key. Each is wrapped in an article. */\n  children: React.ReactNode;\n  /** False once the last page is in. */\n  hasMore: boolean;\n  /** Fetch and append the next page. Return a promise; reject it to show the error row. */\n  onLoadMore: () => unknown;\n  /** Total number of items if known; announced as the feed's size. */\n  total?: number;\n  /** One placeholder row shaped like a real row. Defaults to an avatar and two lines. */\n  skeleton?: React.ReactNode;\n  /** How many placeholder rows show while a page loads. */\n  skeletonCount?: number;\n  /** Shown instead of the list when there is nothing at all. */\n  empty?: React.ReactNode;\n  /** The end-of-list line. Defaults to “You’ve reached the end”. */\n  endMessage?: React.ReactNode;\n  errorMessage?: React.ReactNode;\n  /** How far below the fold to start loading the next page. */\n  rootMargin?: string;\n  /** Retry a failed page once, quietly, before showing the error. Most failures are blips. */\n  retryOnce?: boolean;\n};\n\nexport function InfiniteList({\n  children,\n  hasMore,\n  onLoadMore,\n  total,\n  skeleton,\n  skeletonCount = 3,\n  empty,\n  endMessage = \"You’ve reached the end\",\n  errorMessage = \"Couldn’t load more.\",\n  rootMargin = \"0px 0px 240px 0px\",\n  retryOnce = true,\n  className,\n  onKeyDown,\n  ...rest\n}: InfiniteListProps) {\n  const reduce = useReducedMotion();\n  const items = Children.toArray(children);\n  const count = items.length;\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const [active, setActive] = useState(0);\n  const [freshFrom, setFreshFrom] = useState(count);\n  const scroller = useRef<HTMLDivElement>(null);\n  const sentinel = useRef<HTMLDivElement>(null);\n  const busy = useRef(false);\n  const alive = useRef(true);\n  const countRef = useRef(count);\n  const loadRef = useRef<() => void>(() => {});\n  const retryButton = useRef<HTMLButtonElement>(null);\n  // Set when Try again is pressed: that button is about to disappear, so focus needs somewhere to go.\n  const refocus = useRef(false);\n\n  useEffect(() => {\n    countRef.current = count;\n  });\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n    };\n  }, []);\n\n  const load = async () => {\n    if (busy.current) return;\n    busy.current = true;\n    setFreshFrom(countRef.current);\n    setStatus(\"loading\");\n    let ok = false;\n    for (let attempt = 0; attempt < (retryOnce ? 2 : 1) && !ok; attempt++) {\n      if (attempt) await new Promise((r) => window.setTimeout(r, 1200));\n      if (!alive.current) return;\n      try {\n        await onLoadMore();\n        ok = true;\n      } catch {\n        ok = false;\n      }\n    }\n    busy.current = false;\n    if (alive.current) setStatus(ok ? \"idle\" : \"error\");\n  };\n  useEffect(() => {\n    loadRef.current = load;\n  });\n\n  // Watch a sentinel just past the last row. The observer is rebuilt after\n  // every page, so if the new rows still don't fill the view it fires again.\n  useEffect(() => {\n    const root = scroller.current;\n    const target = sentinel.current;\n    if (!root || !target || !hasMore || status !== \"idle\") return;\n    const io = new IntersectionObserver((entries) => {\n      if (entries.some((e) => e.isIntersecting)) loadRef.current();\n    }, { root, rootMargin });\n    io.observe(target);\n    return () => io.disconnect();\n  }, [hasMore, status, count, rootMargin]);\n\n  useEffect(() => {\n    if (!refocus.current) return;\n    if (status === \"error\") {\n      refocus.current = false;\n      retryButton.current?.focus();\n    } else if (status === \"idle\" && count > freshFrom) {\n      refocus.current = false;\n      scroller.current?.querySelector<HTMLElement>(`[data-index=\"${freshFrom}\"]`)?.focus({ preventScroll: true });\n    }\n  }, [status, count, freshFrom]);\n\n  // If the reader is already at the end of what's loaded, bring the skeletons\n  // or the error row into view so the wait (or the failure) is never off-screen.\n  useEffect(() => {\n    const root = scroller.current;\n    const edge = sentinel.current;\n    if (status === \"idle\" || !root || !edge) return;\n    if (edge.getBoundingClientRect().top > root.getBoundingClientRect().bottom + 16) return;\n    root.scrollTo({ top: root.scrollHeight, behavior: reduce ? \"auto\" : \"smooth\" });\n  }, [status, reduce]);\n\n  // Feed keyboard model: Page Down / Page Up (and the arrows) move between\n  // items, Home / End jump to the first and last loaded item.\n  const focusItem = (i: number) => {\n    const next = Math.max(0, Math.min(count - 1, i));\n    setActive(next);\n    const el = scroller.current?.querySelector<HTMLElement>(`[data-index=\"${next}\"]`);\n    el?.focus({ preventScroll: true });\n    // On the last item of a finished list, show the end line too, not just the row.\n    if (next === count - 1 && !hasMore) scroller.current?.scrollTo({ top: scroller.current.scrollHeight });\n    else el?.scrollIntoView({ block: \"nearest\" });\n  };\n  const handleKey = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(e);\n    const from = (e.target as HTMLElement).closest<HTMLElement>(\"[data-index]\");\n    if (e.defaultPrevented || !from || from !== e.target) return;\n    const i = Number(from.dataset.index);\n    const map: Record<string, number> = { PageDown: i + 1, ArrowDown: i + 1, j: i + 1, PageUp: i - 1, ArrowUp: i - 1, k: i - 1, Home: 0, End: count - 1 };\n    if (!(e.key in map) || e.metaKey || e.ctrlKey || e.altKey) return;\n    e.preventDefault();\n    focusItem(map[e.key]);\n  };\n\n  const toTop = () => {\n    scroller.current?.scrollTo({ top: 0, behavior: reduce ? \"auto\" : \"smooth\" });\n    setActive(0);\n    scroller.current?.querySelector<HTMLElement>('[data-index=\"0\"]')?.focus({ preventScroll: true });\n  };\n\n  const fade = { duration: reduce ? 0.12 : 0.2, ease: ease.out };\n  const current = Math.min(active, Math.max(0, count - 1));\n\n  return (\n    <div\n      ref={scroller}\n      role=\"feed\"\n      aria-busy={status === \"loading\" || undefined}\n      onKeyDown={handleKey}\n      className={cn(\"relative min-h-0 overflow-y-auto overscroll-contain\", className)}\n      {...rest}\n    >\n      {count === 0 && !hasMore && status === \"idle\" ? (\n        <div className=\"grid h-full min-h-40 place-items-center p-6 text-center text-[12.5px] text-fg-3\">{empty ?? \"Nothing here yet\"}</div>\n      ) : (\n        <>\n          {items.map((child, i) => (\n            <motion.article\n              key={isValidElement(child) && child.key != null ? child.key : i}\n              data-index={i}\n              aria-posinset={i + 1}\n              aria-setsize={total ?? (hasMore ? -1 : count)}\n              tabIndex={i === current ? 0 : -1}\n              onFocus={() => setActive(i)}\n              // Rows arriving where skeletons stood crossfade in place: no travel, so nothing looks like it moved.\n              initial={i >= freshFrom ? { opacity: 0 } : false}\n              animate={{ opacity: 1 }}\n              transition={{ ...fade, delay: reduce ? 0 : Math.min(i - freshFrom, 5) * 0.02 }}\n              className=\"rounded-lg outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\"\n            >\n              {child}\n            </motion.article>\n          ))}\n\n          <div ref={sentinel} aria-hidden className=\"h-px\" />\n\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {status === \"loading\" && (\n              <motion.div key=\"skeleton\" aria-hidden initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.1 } }} transition={fade}>\n                {Array.from({ length: skeletonCount }, (_, i) => (\n                  <div key={i} className=\"animate-pulse-soft motion-reduce:animate-none\" style={{ animationDelay: `${i * 160}ms` }}>\n                    {skeleton ?? <SkeletonRow wide={i % 2 === 0} />}\n                  </div>\n                ))}\n              </motion.div>\n            )}\n\n            {status === \"error\" && (\n              <motion.div\n                key=\"error\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                transition={fade}\n                className=\"flex items-center gap-3 rounded-lg px-3 py-3\"\n              >\n                <span className=\"grid size-6 shrink-0 place-items-center rounded-full bg-danger-soft text-danger\">\n                  <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" aria-hidden>\n                    <path d=\"M8 4.75v4\" />\n                    <circle cx=\"8\" cy=\"11.25\" r=\".75\" fill=\"currentColor\" stroke=\"none\" />\n                  </svg>\n                </span>\n                <p role=\"alert\" className=\"min-w-0 flex-1 text-[12.5px] leading-[18px] text-fg-2\">\n                  {errorMessage}\n                </p>\n                <button\n                  ref={retryButton}\n                  type=\"button\"\n                  onClick={() => {\n                    refocus.current = true;\n                    load();\n                  }}\n                  className={cn(\n                    \"group/retry inline-flex h-7 shrink-0 select-none items-center gap-1.5 rounded-md border border-line-2 bg-raised px-2.5 text-[12px] font-medium text-fg shadow-[var(--shadow)]\",\n                    \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                    \"transition-[background-color,border-color,scale] duration-150 ease-out hover:border-fg-4 hover:bg-hover active:scale-[0.97] active:duration-75\",\n                  )}\n                >\n                  <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"transition-transform duration-200 ease-out group-hover/retry:-rotate-45\">\n                    <path d=\"M13 8a5 5 0 1 1-1.5-3.55M13 2.75V5h-2.25\" />\n                  </svg>\n                  Try again\n                </button>\n              </motion.div>\n            )}\n\n            {!hasMore && status === \"idle\" && count > 0 && (\n              <motion.div\n                key=\"end\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n                animate={{ opacity: 1, y: 0 }}\n                transition={fade}\n                className=\"flex items-center justify-between gap-3 px-3 pb-2 pt-4\"\n              >\n                <p className=\"flex min-w-0 items-center gap-2 text-[12px] text-fg-3\">\n                  <span aria-hidden className=\"h-px w-4 bg-line-2\" />\n                  <span className=\"truncate\">{endMessage}</span>\n                </p>\n                <button\n                    type=\"button\"\n                    onClick={toTop}\n                    className={cn(\n                      \"group/top inline-flex h-7 shrink-0 select-none items-center gap-1 rounded-md px-2 text-[12px] font-medium text-fg-2\",\n                      \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                      \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75\",\n                    )}\n                  >\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"transition-transform duration-200 ease-out group-hover/top:-translate-y-px\">\n                      <path d=\"M8 13V3M4 7l4-4 4 4\" />\n                    </svg>\n                    Back to top\n                  </button>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </>\n      )}\n    </div>\n  );\n}\n\n/** The default placeholder: an avatar and two lines at the height of a two-line row. */\nexport function SkeletonRow({ wide = true, className }: { wide?: boolean; className?: string }) {\n  return (\n    <div className={cn(\"flex items-center gap-3 px-3 py-2.5\", className)}>\n      <span className=\"size-8 shrink-0 rounded-lg bg-fg/[0.07]\" />\n      <span className=\"flex min-w-0 flex-1 flex-col gap-2\">\n        <span className={cn(\"h-2.5 rounded-full bg-fg/[0.07]\", wide ? \"w-3/5\" : \"w-2/5\")} />\n        <span className={cn(\"h-2 rounded-full bg-fg/[0.07]\", wide ? \"w-1/3\" : \"w-1/2\")} />\n      </span>\n      <span className=\"h-2.5 w-12 shrink-0 rounded-full bg-fg/[0.07]\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/infinite-list"
}
