{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "error-state",
  "type": "registry:ui",
  "title": "Error State",
  "description": "Says what failed and what to do, retries in place, counts every attempt.",
  "categories": [
    "async"
  ],
  "dependencies": [
    "@base-ui/react",
    "@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",
    "https://www.stealth.pm/r/copy-button.json"
  ],
  "files": [
    {
      "path": "components/ui/error-state.tsx",
      "content": "\"use client\";\nimport { Button } from \"@base-ui/react/button\";\nimport { Collapsible } from \"@base-ui/react/collapsible\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\ntype RetryStatus = \"idle\" | \"pending\";\n\nexport type ErrorStateProps = Omit<React.ComponentProps<\"div\">, \"title\"> & {\n  /** What happened, in plain words: “Couldn’t load invoices”. */\n  heading: React.ReactNode;\n  /** Why, if it helps, and what to do. */\n  description?: React.ReactNode;\n  /** block fills a region; inline is one row inside a card or under a field. */\n  variant?: \"block\" | \"inline\";\n  /** danger for failures; warning for conditions that will pass, like being offline. */\n  tone?: \"danger\" | \"warning\";\n  /** Runs the request again. Return a promise: the button spins while it runs, and a rejection counts as another failed attempt. */\n  onRetry?: () => unknown;\n  retryLabel?: string;\n  /** A short machine code shown in the details and the copied report, e.g. ERR_UPSTREAM_TIMEOUT. */\n  code?: string;\n  /** HTTP status, if there was one. */\n  status?: number;\n  /** A request or trace id support can look up. */\n  requestId?: string;\n  /** When it failed. Printed in UTC so server and client agree. */\n  occurredAt?: number | Date;\n  /** The raw message or stack excerpt, for the details panel. */\n  details?: string;\n  /** More actions beside Try again, e.g. a status page link. */\n  children?: React.ReactNode;\n};\n\nconst svg = {\n  viewBox: \"0 0 16 16\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 1.4,\n  strokeLinecap: \"round\" as const,\n  strokeLinejoin: \"round\" as const,\n  \"aria-hidden\": true,\n};\n\nexport function ErrorState({\n  heading,\n  description,\n  variant = \"block\",\n  tone = \"danger\",\n  onRetry,\n  retryLabel = \"Try again\",\n  code,\n  status,\n  requestId,\n  occurredAt,\n  details,\n  children,\n  className,\n  ...rest\n}: ErrorStateProps) {\n  const reduce = useReducedMotion();\n  const [retry, setRetry] = useState<RetryStatus>(\"idle\");\n  const [spinning, setSpinning] = useState(false);\n  // Every failed retry counts. Without it a failed retry looks exactly like nothing happened.\n  const [attempts, setAttempts] = useState(1);\n  const [announce, setAnnounce] = useState(\"\");\n  const busy = useRef(false);\n  const alive = useRef(true);\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n    };\n  }, []);\n\n  const run = async () => {\n    if (!onRetry || busy.current) return;\n    busy.current = true;\n    setRetry(\"pending\");\n    setAnnounce(\"Trying again…\");\n    let shownAt = 0;\n    const reveal = window.setTimeout(() => {\n      shownAt = performance.now();\n      setSpinning(true);\n    }, 150);\n    let ok = true;\n    try {\n      await onRetry();\n    } catch {\n      ok = false;\n    }\n    window.clearTimeout(reveal);\n    if (shownAt) {\n      const rest = 450 - (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    setRetry(\"idle\");\n    if (!ok) {\n      setAttempts((a) => a + 1);\n      setAnnounce(`Still failing after ${attempts + 1} attempts`);\n    } else setAnnounce(\"\");\n  };\n\n  const when = occurredAt == null ? undefined : new Date(occurredAt).toISOString().replace(\"T\", \" \").slice(0, 19) + \" UTC\";\n  const rows = [\n    [\"Code\", code],\n    [\"Status\", status != null ? String(status) : undefined],\n    [\"Request\", requestId],\n    [\"Time\", when],\n  ].filter((r): r is [string, string] => !!r[1]);\n  const report = [...rows.map(([k, v]) => `${k}: ${v}`), details ? `\\n${details}` : \"\"].join(\"\\n\").trim();\n  const hasDetails = rows.length > 0 || !!details;\n  const pending = retry === \"pending\";\n\n  const retryButton = onRetry && (\n    <Button\n      onClick={run}\n      disabled={pending}\n      focusableWhenDisabled\n      aria-busy={pending || undefined}\n      className={cn(\n        \"group/retry relative inline-flex shrink-0 select-none items-center justify-center font-medium\",\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        variant === \"inline\"\n          ? \"h-7 gap-1.5 rounded-md px-2 text-[12px] text-fg hover:bg-hover\"\n          : \"h-8 gap-2 rounded-lg border border-line-2 bg-raised px-3 text-[12.5px] text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover\",\n        pending ? \"cursor-default\" : \"active:scale-[0.97]\",\n        // Icon-sized on screen, finger-sized on touch.\n        variant === \"inline\" && \"before:absolute before:-inset-x-1 before:-inset-y-2 before:content-[''] pointer-fine:before:hidden\",\n      )}\n    >\n      <span className=\"relative grid size-3.5 place-items-center\">\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={spinning ? \"spin\" : \"idle\"}\n            className=\"absolute inset-0 grid place-items-center\"\n            initial={{ opacity: 0, scale: 0.5 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, scale: 0.5, transition: { duration: 0.1 } }}\n            transition={reduce ? { duration: 0.12, scale: { duration: 0 } } : spring.pop}\n          >\n            {spinning ? (\n              <svg {...svg} className=\"size-3.5 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              // Winds back as the pointer arrives: “again”, before the label is read.\n              <svg {...svg} className=\"size-3.5 transition-transform duration-300 ease-out group-hover/retry:-rotate-[60deg]\">\n                <path d=\"M13 8a5 5 0 1 1-1.5-3.55M13 2.75V5h-2.25\" />\n              </svg>\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n      {retryLabel}\n    </Button>\n  );\n\n  const toneText = tone === \"warning\" ? \"text-warning\" : \"text-danger\";\n\n  if (variant === \"inline\")\n    return (\n      <div data-variant=\"inline\" data-tone={tone} className={cn(\"flex min-h-8 min-w-0 items-center gap-2\", className)} {...rest}>\n        <span className={cn(\"grid size-4 shrink-0 place-items-center\", toneText)}>\n          <Glyph tone={tone} small attempt={attempts} reduce={!!reduce} />\n        </span>\n        <p role=\"alert\" className=\"min-w-0 flex-1 text-[12.5px] leading-[18px] text-fg-2\">\n          <span className=\"text-fg\">{heading}</span>\n          {description != null && <span className=\"text-fg-3\"> {description}</span>}\n          {attempts > 1 && <Attempts n={attempts} />}\n        </p>\n        {retryButton}\n        {children}\n        <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {announce}\n        </span>\n      </div>\n    );\n\n  return (\n    // A container query, not a viewport one: in a narrow slot the tile stacks above the words so the details get the full width.\n    <div data-variant=\"block\" data-tone={tone} className={cn(\"@container min-w-0\", className)} {...rest}>\n      <div className=\"flex flex-col gap-3 @sm:flex-row @sm:gap-3.5\">\n        <span\n          className={cn(\n            \"grid size-8 shrink-0 place-items-center rounded-lg border\",\n            tone === \"warning\" ? \"border-warning/25 bg-warning-soft text-warning\" : \"border-danger/25 bg-danger-soft text-danger\",\n          )}\n        >\n          <Glyph tone={tone} attempt={attempts} reduce={!!reduce} />\n        </span>\n\n        <div className=\"flex min-w-0 flex-1 flex-col\">\n          <div role=\"alert\">\n            <p className=\"text-balance @sm:pt-[5px] text-[14px] font-medium leading-[20px] tracking-[-0.012em] text-fg\">{heading}</p>\n            {description != null && <p className=\"mt-1 text-pretty text-[12.5px] leading-[19px] text-fg-2\">{description}</p>}\n          </div>\n          {/* Grows open on the first repeat failure rather than shoving the actions down in one frame. */}\n          <AnimatePresence initial={false}>\n            {attempts > 1 && (\n              <motion.p\n                key=\"attempts\"\n                className=\"overflow-hidden text-[12px] text-fg-3\"\n                initial={{ height: 0, opacity: 0 }}\n                animate={{ height: \"auto\", opacity: 1 }}\n                transition={reduce ? { duration: 0.15, height: { duration: 0 } } : { duration: 0.26, ease: ease.out }}\n              >\n                <span className=\"block pt-1.5\">\n                  <Attempts n={attempts} block />\n                </span>\n              </motion.p>\n            )}\n          </AnimatePresence>\n\n          {(retryButton || children) && (\n            <div className=\"mt-3.5 flex flex-wrap items-center gap-2\">\n              {retryButton}\n              {children}\n            </div>\n          )}\n\n          {hasDetails && (\n            <Collapsible.Root className=\"mt-4 border-t border-line pt-2\">\n              <Collapsible.Trigger\n                className={cn(\n                  \"group/details -mx-1.5 inline-flex h-7 max-w-[calc(100%+12px)] select-none items-center gap-1 rounded-md px-1.5 text-[12px] font-medium text-fg-3\",\n                  \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n                  \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg-2 active:scale-[0.98] data-panel-open:text-fg-2\",\n                )}\n              >\n                <svg\n                  {...svg}\n                  className=\"size-3.5 transition-transform duration-200 ease-in-out-quart group-data-panel-open/details:rotate-90 motion-reduce:transition-none\"\n                >\n                  <path d=\"m6.25 4.5 3.5 3.5-3.5 3.5\" />\n                </svg>\n                <span className=\"shrink-0 whitespace-nowrap\">Technical details</span>\n                {code && <span className=\"ml-1 min-w-0 truncate font-mono text-[11px] font-normal text-fg-4\">{code}</span>}\n              </Collapsible.Trigger>\n              <Collapsible.Panel\n                className={cn(\n                  \"h-(--collapsible-panel-height) overflow-hidden transition-[height,opacity] duration-250 ease-in-out-quart\",\n                  \"data-starting-style:h-0 data-starting-style:opacity-0 data-ending-style:h-0 data-ending-style:opacity-0 data-ending-style:duration-180\",\n                  \"[&[hidden]:not([hidden='until-found'])]:hidden\",\n                )}\n              >\n                <div className=\"relative mt-2 rounded-lg border border-line bg-frame\">\n                  <div className=\"absolute right-1 top-1\">\n                    <CopyButton value={report} iconOnly variant=\"ghost\" size=\"sm\" label=\"Copy error details\" copiedLabel=\"Copied error details\" />\n                  </div>\n                  {rows.length > 0 && (\n                    <dl className=\"grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 px-3 py-2.5 pr-10 font-mono text-[11.5px] leading-[17px]\">\n                      {rows.map(([k, v]) => (\n                        <div key={k} className=\"contents\">\n                          <dt className=\"text-fg-4\">{k}</dt>\n                          <dd className=\"min-w-0 text-fg-2 [overflow-wrap:anywhere]\">{v}</dd>\n                        </div>\n                      ))}\n                    </dl>\n                  )}\n                  {details && (\n                    <pre\n                      className={cn(\n                        \"max-h-32 overflow-auto whitespace-pre-wrap break-words px-3 pb-2.5 font-mono text-[11px] leading-[16px] text-fg-3\",\n                        rows.length ? \"border-t border-line pt-2.5\" : \"pt-2.5 pr-10\",\n                      )}\n                    >\n                      {details}\n                    </pre>\n                  )}\n                </div>\n              </Collapsible.Panel>\n            </Collapsible.Root>\n          )}\n        </div>\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\n// “Attempt 3 failed”: the number rolls so a second failure is visibly different from the first.\nfunction Attempts({ n, block }: { n: number; block?: boolean }) {\n  return (\n    <span className={cn(\"tabular\", block ? \"\" : \"ml-1.5 text-fg-4\")}>\n      {block ? \"Still failing · attempt \" : \"· attempt \"}\n      <NumberFlow value={n} />\n    </span>\n  );\n}\n\n// The mark redraws each time an attempt fails: the same answer, visibly given again.\nfunction Glyph({ tone, small, attempt, reduce }: { tone: \"danger\" | \"warning\"; small?: boolean; attempt: number; reduce: boolean }) {\n  const draw = {\n    initial: { pathLength: 0 },\n    animate: { pathLength: 1 },\n    transition: reduce ? { duration: 0 } : { duration: 0.34, ease: ease.out, delay: 0.05 },\n  };\n  return (\n    <motion.svg\n      key={attempt}\n      {...svg}\n      className={small ? \"size-3.5\" : \"size-4\"}\n      initial={{ scale: attempt > 1 && !reduce ? 0.7 : 1 }}\n      animate={{ scale: 1 }}\n      transition={spring.pop}\n    >\n      {tone === \"warning\" ? (\n        <>\n          <path d=\"M7.1 2.9a1 1 0 0 1 1.8 0l5 9.1a1 1 0 0 1-.9 1.5H3a1 1 0 0 1-.9-1.5z\" />\n          <motion.path d=\"M8 6.25v3\" {...(attempt > 1 ? draw : {})} />\n          <circle cx=\"8\" cy=\"11.1\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n        </>\n      ) : (\n        <>\n          <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n          <motion.path d=\"M8 5v3.5\" {...(attempt > 1 ? draw : {})} />\n          <circle cx=\"8\" cy=\"10.9\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n        </>\n      )}\n    </motion.svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/error-state"
}
