{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "load-more",
  "type": "registry:ui",
  "title": "Load More",
  "description": "Appends the next page with a stagger, moves focus and never jumps.",
  "categories": [
    "async"
  ],
  "dependencies": [
    "@number-flow/react",
    "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/load-more.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Children, isValidElement, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\ntype Status = \"idle\" | \"loading\" | \"error\";\n\nexport type LoadMoreProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The rows loaded so far, each with a stable key. Each is wrapped in an li. */\n  children: React.ReactNode;\n  /** False once the last page is in. The button becomes the end marker. */\n  hasMore: boolean;\n  /** Fetch and append the next page. Return a promise; reject it and the button offers a retry. */\n  onLoadMore: () => unknown;\n  /** Total number of items, when known. Adds “Showing 20 of 128” under the button. */\n  total?: number;\n  /** Accessible name for the list, e.g. “Activity”. */\n  listLabel?: string;\n  label?: string;\n  loadingLabel?: string;\n  retryLabel?: string;\n  endLabel?: string;\n  /** Shown above the button when a page fails to load. */\n  errorMessage?: React.ReactNode;\n  listClassName?: string;\n  /** Wait this long before the spinner shows, so fast pages never flash it. */\n  pendingDelay?: number;\n  /** Once shown, keep the spinner at least this long so it never flickers. */\n  minPending?: number;\n};\n\nexport function LoadMore({\n  children,\n  hasMore,\n  onLoadMore,\n  total,\n  listLabel,\n  label = \"Load more\",\n  loadingLabel = \"Loading…\",\n  retryLabel = \"Try again\",\n  endLabel = \"All caught up\",\n  errorMessage = \"Couldn’t load more. Check your connection and try again.\",\n  listClassName,\n  pendingDelay = 150,\n  minPending = 400,\n  className,\n  ...rest\n}: LoadMoreProps) {\n  const reduce = useReducedMotion();\n  const items = Children.toArray(children);\n  const count = items.length;\n\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const [spinning, setSpinning] = useState(false);\n  const [announce, setAnnounce] = useState(\"\");\n  // Items at or past this index arrived by pressing the button; everything before it was already there.\n  const [freshFrom, setFreshFrom] = useState(count);\n  const list = useRef<HTMLUListElement>(null);\n  const footer = useRef<HTMLDivElement>(null);\n  const focusAt = useRef<number | null>(null);\n  const busy = useRef(false);\n  const alive = useRef(true);\n  const countRef = useRef(count);\n  useEffect(() => {\n    countRef.current = count;\n  });\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n    };\n  }, []);\n\n  // Once the new rows are in the DOM, move focus to the first of them. Keyboard\n  // and screen-reader users land on what they asked for instead of on a button\n  // that is now further down.\n  useEffect(() => {\n    const at = focusAt.current;\n    if (at == null || count <= at) return;\n    focusAt.current = null;\n    const el = list.current?.children[at] as HTMLElement | undefined;\n    el?.focus({ preventScroll: true });\n    el?.scrollIntoView({ block: \"nearest\", behavior: reduce ? \"auto\" : \"smooth\" });\n  }, [count, reduce]);\n\n  // The error sits under the button so the button stays under the pointer; make sure it is in view.\n  useEffect(() => {\n    if (status === \"error\") footer.current?.scrollIntoView({ block: \"nearest\", behavior: reduce ? \"auto\" : \"smooth\" });\n  }, [status, reduce]);\n\n  const load = async () => {\n    // One page at a time: a second press while loading is a double request.\n    if (busy.current) return;\n    busy.current = true;\n    const before = countRef.current;\n    setFreshFrom(before);\n    setStatus(\"loading\");\n    let shownAt = 0;\n    const reveal = window.setTimeout(() => {\n      shownAt = performance.now();\n      setSpinning(true);\n    }, pendingDelay);\n\n    let ok = true;\n    try {\n      await onLoadMore();\n    } catch {\n      ok = false;\n    }\n    window.clearTimeout(reveal);\n    if (shownAt) {\n      const rest = minPending - (performance.now() - shownAt);\n      if (rest > 0) await new Promise((r) => window.setTimeout(r, rest));\n    }\n    busy.current = false;\n    if (!alive.current) return;\n    setSpinning(false);\n    if (ok) {\n      focusAt.current = before;\n      setStatus(\"idle\");\n      const added = countRef.current - before;\n      setAnnounce(added > 0 ? `Loaded ${added} more${total != null ? `. Showing ${countRef.current} of ${total}` : \"\"}` : \"\");\n    } else {\n      setStatus(\"error\");\n      setAnnounce(typeof errorMessage === \"string\" ? errorMessage : \"Couldn’t load more\");\n    }\n  };\n\n  const shown = status === \"loading\" && spinning ? \"loading\" : status === \"error\" ? \"error\" : \"idle\";\n  const text = { idle: label, loading: loadingLabel, error: retryLabel }[shown];\n  const ended = !hasMore;\n\n  return (\n    <div className={cn(\"flex min-w-0 flex-col\", className)} {...rest}>\n      <ul ref={list} aria-label={listLabel} aria-busy={status === \"loading\" || undefined} className={cn(\"flex flex-col\", listClassName)}>\n        {items.map((child, i) => {\n          const fresh = i >= freshFrom;\n          const delay = reduce ? 0 : Math.min(i - freshFrom, 7) * 0.03;\n          return (\n            <motion.li\n              key={isValidElement(child) && child.key != null ? child.key : i}\n              tabIndex={-1}\n              initial={fresh ? (reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(2px)\" }) : false}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              transition={{ duration: reduce ? 0.15 : 0.32, ease: ease.out, delay: fresh ? delay : 0 }}\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.li>\n          );\n        })}\n      </ul>\n\n      {/* Never the scroll anchor: new rows push it down instead of the page jumping to follow it. */}\n      <div ref={footer} className=\"flex flex-col items-center gap-2 pt-3 [overflow-anchor:none]\">\n        <div className=\"grid w-full place-items-center\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {ended ? (\n              <EndMark key=\"end\" label={endLabel} reduce={!!reduce} />\n            ) : (\n              <motion.button\n                key=\"button\"\n                type=\"button\"\n                onClick={load}\n                data-state={shown}\n                aria-busy={status === \"loading\" || undefined}\n                exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.96, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n                className={cn(\n                  \"relative col-start-1 row-start-1 inline-flex h-8 select-none items-center justify-center gap-2 rounded-lg border px-3 text-[12.5px] font-medium tracking-[-0.005em] 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,color,scale] duration-150 ease-out active:duration-75\",\n                  status === \"loading\"\n                    ? \"cursor-default border-line-2 bg-hover text-fg-2\"\n                    : shown === \"error\"\n                      ? \"border-danger/40 bg-raised text-danger hover:border-danger/60 hover:bg-danger-soft active:scale-[0.97]\"\n                      : \"border-line-2 bg-raised text-fg hover:border-fg-4 hover:bg-hover active:scale-[0.97]\",\n                )}\n              >\n                <span className=\"relative grid size-4 place-items-center\" aria-hidden>\n                  <AnimatePresence initial={false}>\n                    <motion.span\n                      key={shown}\n                      className=\"absolute inset-0 grid place-items-center\"\n                      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n                      animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                      exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n                      // Blur tweens on its own: a spring would overshoot it below zero.\n                      transition={reduce ? { duration: 0.12 } : { ...spring.pop, filter: { duration: 0.18, ease: ease.out } }}\n                    >\n                      {shown === \"loading\" ? <Spinner /> : shown === \"error\" ? <RetryGlyph /> : <MoreGlyph />}\n                    </motion.span>\n                  </AnimatePresence>\n                </span>\n                {/* Every label shares one cell, so the button never changes width. */}\n                <span className=\"grid text-left\">\n                  {[label, loadingLabel, retryLabel].map((l) => (\n                    <span key={l} aria-hidden className=\"invisible col-start-1 row-start-1\">\n                      {l}\n                    </span>\n                  ))}\n                  <span className=\"col-start-1 row-start-1\">{text}</span>\n                </span>\n              </motion.button>\n            )}\n          </AnimatePresence>\n        </div>\n\n        <AnimatePresence initial={false}>\n          {status === \"error\" && !ended && (\n            <motion.p\n              key=\"error\"\n              role=\"alert\"\n              className=\"max-w-[36ch] text-balance text-center text-[12px] leading-[17px] text-danger\"\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={{ duration: 0.2, ease: ease.out }}\n            >\n              {errorMessage}\n            </motion.p>\n          )}\n        </AnimatePresence>\n\n        {total != null && (\n          <p className=\"text-[11.5px] tabular text-fg-3\">\n            {ended && count >= total ? (\n              <>Showing all {total.toLocaleString(\"en-US\")}</>\n            ) : (\n              <>\n                Showing <NumberFlow value={count} className=\"text-fg-2\" /> of {total.toLocaleString(\"en-US\")}\n              </>\n            )}\n          </p>\n        )}\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n        {ended && announce ? `. ${endLabel}` : \"\"}\n      </span>\n    </div>\n  );\n}\n\n// The end of the list: hairlines draw out from a tick, so it reads as a\n// finish line rather than a button that went missing.\nfunction EndMark({ label, reduce }: { label: string; reduce: boolean }) {\n  const rule = (origin: \"left\" | \"right\") => (\n    <motion.span\n      aria-hidden\n      className={cn(\"h-px flex-1 bg-line-2\", origin === \"right\" ? \"origin-right\" : \"origin-left\")}\n      initial={reduce ? { opacity: 0 } : { scaleX: 0, opacity: 0 }}\n      animate={{ scaleX: 1, opacity: 1 }}\n      transition={{ duration: reduce ? 0.15 : 0.5, ease: ease.out, delay: reduce ? 0 : 0.12 }}\n    />\n  );\n  return (\n    <motion.div\n      className=\"col-start-1 row-start-1 flex h-8 w-full items-center gap-3\"\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: reduce ? 0.15 : 0.28, ease: ease.out, delay: reduce ? 0 : 0.06 }}\n    >\n      {rule(\"right\")}\n      <span className=\"inline-flex shrink-0 items-center gap-1.5 text-[12px] font-medium text-fg-2\">\n        <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"text-fg-3\">\n          <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n          <motion.path\n            d=\"m5.5 8.25 1.75 1.75 3.25-3.75\"\n            initial={reduce ? false : { pathLength: 0 }}\n            animate={{ pathLength: 1 }}\n            transition={{ duration: 0.32, ease: ease.out, delay: 0.18 }}\n          />\n        </svg>\n        {label}\n      </span>\n      {rule(\"left\")}\n    </motion.div>\n  );\n}\n\nconst svg = { width: 16, height: 16, viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.4, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true };\n\nfunction Spinner() {\n  return (\n    <svg {...svg} className=\"animate-spin [animation-duration:0.8s]\">\n      <circle cx=\"8\" cy=\"8\" r=\"5.75\" opacity=\"0.22\" />\n      <path d=\"M8 2.25a5.75 5.75 0 0 1 5.75 5.75\" />\n    </svg>\n  );\n}\n\n// A chevron that nudges down on hover: more is below.\nfunction MoreGlyph() {\n  return (\n    <svg {...svg} className=\"transition-transform duration-200 ease-out [button:hover_&]:translate-y-px\">\n      <path d=\"m4.5 6.25 3.5 3.5 3.5-3.5\" />\n    </svg>\n  );\n}\n\nfunction RetryGlyph() {\n  return (\n    <svg {...svg}>\n      <path d=\"M13 8a5 5 0 1 1-1.5-3.55M13 2.75V5h-2.25\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/load-more"
}
