{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"thinking-indicator","type":"registry:component","title":"Thinking indicator","description":"Four quiet loaders for the gap before the first token, with a label, a rolling timer and a finish that draws itself.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/thinking-indicator.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * THINKING INDICATOR: the gap before the first token\n *\n *   orbit  a dot circling a hairline ring\n *   dots   three dots in a soft wave\n *   pulse  a breathing dot\n *   scan   a bar sweeping a short track\n *\n * A label with a sweep of light and a timer whose seconds roll\n * (\"Churning 44s\") while running. When it's done the mark\n * blurs into a check that draws itself, the label morphs to\n * \"Done in\" and the time gains its tenths: \"Done in 44.6s\".\n * ───────────────────────────────────────────────────────── */\n\nexport type IndicatorVariant = \"orbit\" | \"dots\" | \"pulse\" | \"scan\";\n\nexport interface ThinkingIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {\n  variant?: IndicatorVariant;\n  status?: \"running\" | \"done\";\n  label?: string;\n  doneLabel?: string;\n  /** Start time (ms); shows a live timer. */\n  startedAt?: number;\n}\n\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\n// A light that sweeps across the label. A mask, not a text clip, so it reaches letters that are mid-morph.\nconst SHEEN =\n  \"text-foreground [mask-image:linear-gradient(90deg,rgb(0_0_0/0.45)_35%,#000_50%,rgb(0_0_0/0.45)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none] motion-reduce:text-foreground/70\";\nconst TENTHS = { minimumFractionDigits: 1, maximumFractionDigits: 1 };\nconst WHOLE = { maximumFractionDigits: 0 };\nconst TWO_DIGITS = { minimumIntegerDigits: 2, maximumFractionDigits: 0 };\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\nfunction formatElapsed(ms: number) {\n  const s = Math.max(0, ms) / 1000;\n  return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m ${String(Math.floor(s % 60)).padStart(2, \"0\")}s`;\n}\n\n/** Seconds that roll up as they pass; minutes slide open at the first minute; tenths join once it's settled. */\nfunction Elapsed({ ms, settled, reduced }: { ms: number; settled: boolean; reduced: boolean }) {\n  const total = Math.max(0, ms) / 1000;\n  const minutes = Math.floor(total / 60);\n  const seconds = minutes ? Math.floor(total % 60) : settled ? Math.floor(total * 10) / 10 : Math.floor(total);\n  return (\n    <span className=\"inline-flex items-baseline whitespace-nowrap\">\n      <span\n        className=\"grid\"\n        style={{ gridTemplateColumns: minutes ? \"1fr\" : \"0fr\", opacity: minutes ? 1 : 0, transition: reduced ? \"none\" : `grid-template-columns 420ms ${MORPH}, opacity 300ms ${MORPH}` }}\n      >\n        <span className=\"min-w-0 [clip-path:inset(-4px_0)]\">\n          <NumberRoll value={minutes} suffix=\"m\" duration={500} />\n          &nbsp;\n        </span>\n      </span>\n      <NumberRoll value={seconds} format={minutes ? TWO_DIGITS : settled ? TENTHS : WHOLE} suffix=\"s\" direction=\"up\" duration={500} />\n    </span>\n  );\n}\n\nfunction Mark({ variant }: { variant: IndicatorVariant }) {\n  if (variant === \"dots\")\n    return (\n      <span aria-hidden=\"true\" className=\"flex h-4 items-center gap-[3px]\">\n        {[0, 150, 300].map((delay) => (\n          <span\n            key={delay}\n            className=\"size-1 rounded-full bg-foreground animate-[ui-bounce_1.2s_ease-in-out_infinite] motion-reduce:animate-none\"\n            style={{ animationDelay: `${delay}ms` }}\n          />\n        ))}\n      </span>\n    );\n  if (variant === \"pulse\")\n    return (\n      <span aria-hidden=\"true\" className=\"flex size-4 items-center justify-center\">\n        <span className=\"size-2.5 rounded-full bg-foreground animate-[ui-breathe_1.6s_ease-in-out_infinite] motion-reduce:animate-none\" />\n      </span>\n    );\n  if (variant === \"scan\")\n    return (\n      <span aria-hidden=\"true\" className=\"flex h-4 items-center\">\n        <span className=\"relative h-[3px] w-6 overflow-hidden rounded-full bg-border\">\n          <span className=\"absolute inset-y-0 left-0 w-2/5 rounded-full bg-foreground animate-[ui-scan_1.1s_cubic-bezier(0.45,0,0.55,1)_infinite] motion-reduce:animate-none\" />\n        </span>\n      </span>\n    );\n  return (\n    <span aria-hidden=\"true\" className=\"relative flex size-4 items-center justify-center\">\n      <span className=\"absolute inset-[1.5px] rounded-full border border-border\" />\n      <span className=\"absolute inset-0 animate-spin [animation-duration:1.4s] motion-reduce:animate-none\">\n        <span className=\"absolute left-1/2 top-0 size-[5px] -translate-x-1/2 rounded-full bg-foreground\" />\n      </span>\n    </span>\n  );\n}\n\nexport function ThinkingIndicator({\n  variant = \"orbit\",\n  status = \"running\",\n  label = \"Thinking\",\n  doneLabel,\n  startedAt,\n  className = \"\",\n  ...props\n}: ThinkingIndicatorProps) {\n  const reduced = useReducedMotion();\n  const running = status === \"running\";\n  const [now, setNow] = React.useState(startedAt ?? 0);\n  const [endedAt, setEndedAt] = React.useState<number | undefined>(undefined);\n\n  // Re-render on each whole second of the run (not every frame), and remember when it settled.\n  React.useEffect(() => {\n    if (!running) {\n      setEndedAt((prev) => prev ?? Date.now());\n      return;\n    }\n    setEndedAt(undefined);\n    if (startedAt === undefined) return;\n    let timer = 0;\n    const tick = () => {\n      const t = Date.now();\n      setNow(t);\n      timer = window.setTimeout(tick, 1000 - ((t - startedAt) % 1000) + 5);\n    };\n    tick();\n    return () => window.clearTimeout(timer);\n  }, [running, startedAt]);\n\n  const elapsed = startedAt === undefined ? undefined : (running ? now : (endedAt ?? now)) - startedAt;\n  const showTime = elapsed !== undefined && (running || doneLabel === undefined);\n  const text = running ? label : (doneLabel ?? (elapsed !== undefined ? \"Done in\" : \"Done\"));\n\n  return (\n    <div className={`inline-flex items-center gap-2.5 text-[13px] ${className}`} {...props}>\n      <span aria-hidden=\"true\" className=\"relative flex h-4 min-w-4 items-center justify-center\">\n        {/* The mark stops moving once it has faded out. */}\n        <span className={running ? \"\" : \"[&_*]:[animation-play-state:paused]\"} style={swap(running, reduced)}>\n          <Mark variant={variant} />\n        </span>\n        <span className=\"absolute inset-0 flex items-center justify-center text-muted-foreground\" style={swap(!running, reduced)}>\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4\">\n            <path\n              d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              pathLength={1}\n              strokeDasharray={1}\n              style={{ strokeDashoffset: running ? 1 : 0, transition: !running && !reduced ? `stroke-dashoffset 420ms ${MORPH} 120ms` : \"none\" }}\n            />\n          </svg>\n        </span>\n      </span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-baseline gap-2\">\n        <span className={`font-medium transition-colors duration-300 ${running ? SHEEN : \"text-foreground/75\"}`}>\n          <TextMorph>{text}</TextMorph>\n        </span>\n        {elapsed !== undefined && (\n          <span\n            className=\"-ml-2 grid\"\n            style={{ gridTemplateColumns: showTime ? \"1fr\" : \"0fr\", opacity: showTime ? 1 : 0, transition: reduced ? \"none\" : `grid-template-columns 420ms ${MORPH}, opacity 300ms ${MORPH}` }}\n          >\n            <span className=\"min-w-0 pl-2 tabular-nums text-muted-foreground [clip-path:inset(-4px_0)]\">\n              <Elapsed ms={elapsed} settled={!running} reduced={reduced} />\n            </span>\n          </span>\n        )}\n      </span>\n      {/* What it's doing, for screen readers: the label while it works, then the outcome. Never the ticking timer. */}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {running ? label : showTime && elapsed !== undefined ? `${text} ${formatElapsed(elapsed)}` : text}\n      </span>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-sheen":{"from":{"mask-position":"150% 0"},"to":{"mask-position":"-50% 0"}},"@keyframes ui-bounce":{"0%, 80%, 100%":{"transform":"translateY(0)","opacity":"0.35"},"40%":{"transform":"translateY(-3px)","opacity":"1"}},"@keyframes ui-breathe":{"0%, 100%":{"transform":"scale(0.82)","opacity":"0.55"},"50%":{"transform":"scale(1)","opacity":"1"}},"@keyframes ui-scan":{"from":{"transform":"translateX(-100%)"},"to":{"transform":"translateX(250%)"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/thinking-indicator"}