{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"thinking-loader-clean","type":"registry:ui","title":"Thinking Loader (Clean)","description":"A small working pill for AI replies – an eight-ray spinner and a shimmering label that rolls through phases and settles on a check","dependencies":[],"files":[{"path":"registry/thinking-loader-clean/thinking-loader-clean.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\n\n/*\n * Thinking Loader (clean style) – a small \"the model is working\" chip, in a single self-contained file.\n * One white chip with hairline shadows, a thin eight-ray spinner in the accent blue and a quiet gray\n * label with a darker band sweeping over it. The label walks through phases (Thinking → Searching\n * the web → …): the old one rolls up and blurs out, the new one rolls in, and the chip glides to its\n * new width. When done, the spinner turns into a check and the label settles on \"Thought for 6s\".\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type ThinkingLoaderProps = {\n  /** Labels to walk through, one after another */\n  phases?: string[];\n  /** Time per phase, in ms */\n  interval?: number;\n  /** Show a specific phase instead of advancing on a timer – e.g. from your stream events */\n  phase?: number;\n  /** Start over after the last phase instead of holding it */\n  loop?: boolean;\n  /** Finished: the spinner turns into a check and the label into doneLabel */\n  done?: boolean;\n  /** Label once done – a string, or a function that gets the seconds since the loader started */\n  doneLabel?: string | ((seconds: number) => string);\n  size?: \"sm\" | \"md\";\n  /** \"pill\" = bordered capsule, \"plain\" = just the spinner and the label, e.g. inside a chat bubble */\n  variant?: \"pill\" | \"plain\";\n  className?: string;\n};\n\nconst DEFAULT_PHASES = [\"Thinking\", \"Searching the web\", \"Reading sources\", \"Writing response\"];\nconst RAYS = Array.from({ length: 8 }, (_, i) => i);\n// 75s reads \"1m 15s\", a long run \"2h 05m\" or \"3d 4h\"\nconst took = (s: number) => {\n  if (s < 60) return `${s}s`;\n  const m = Math.floor(s / 60);\n  if (m < 60) return `${m}m ${s % 60}s`;\n  const h = Math.floor(m / 60);\n  return h < 24 ? `${h}h ${String(m % 60).padStart(2, \"0\")}m` : `${Math.floor(h / 24)}d ${h % 24}h`;\n};\n\nexport function ThinkingLoader({\n  phases: phasesProp = DEFAULT_PHASES,\n  interval: intervalProp = 2200,\n  phase,\n  loop = false,\n  done = false,\n  doneLabel = (s) => `Thought for ${took(s)}`,\n  size = \"md\",\n  variant = \"pill\",\n  className,\n}: ThinkingLoaderProps) {\n  // An empty or missing list still says something; 0 or NaN never spins the timer every frame\n  const given = Array.isArray(phasesProp) ? phasesProp.map((p) => String(p ?? \"\")) : [];\n  const phases = given.length ? given : DEFAULT_PHASES.slice(0, 1);\n  const interval = Number.isFinite(intervalProp) && intervalProp > 0 ? Math.max(intervalProp, 200) : 2200;\n  const [step, setStep] = useState(0);\n  const [started, setStarted] = useState(() => Date.now());\n  const [seconds, setSeconds] = useState<number | null>(null);\n\n  // Read the clock in the same render that turns done on – and restart it when work starts again\n  const elapsed = seconds ?? Math.max(1, Math.round((Date.now() - started) / 1000));\n  if (done && seconds === null) setSeconds(elapsed);\n  if (!done && seconds !== null) {\n    setSeconds(null);\n    setStarted(Date.now());\n    setStep(0);\n  }\n\n  // Advance through the phases on a timer – unless the phase is controlled\n  useEffect(() => {\n    if (done || (phase !== undefined && Number.isFinite(phase)) || phases.length < 2) return;\n    const id = window.setInterval(() => {\n      setStep((s) => (s + 1 < phases.length ? s + 1 : loop ? 0 : s));\n    }, interval);\n    return () => window.clearInterval(id);\n  }, [done, phase, phases.length, interval, loop]);\n\n  const wanted = phase !== undefined && Number.isFinite(phase) ? Math.floor(phase) : step;\n  const index = Math.min(Math.max(wanted, 0), Math.max(phases.length - 1, 0));\n  const label = String((done ? (typeof doneLabel === \"function\" ? doneLabel(elapsed) : doneLabel) : phases[index]) ?? \"\");\n\n  // The label on screen and the one rolling out – swapped during render so both animate in the same frame\n  const [shown, setShown] = useState({ key: 0, text: label });\n  const [leaving, setLeaving] = useState<{ key: number; text: string } | null>(null);\n  if (label !== shown.text) {\n    setLeaving(shown);\n    setShown({ key: shown.key + 1, text: label });\n  }\n\n  // The text box takes the width of the incoming label, so the pill glides to its new size\n  const root = useRef<HTMLSpanElement>(null);\n  const box = useRef<HTMLSpanElement>(null);\n  const current = useRef<HTMLSpanElement>(null);\n  const measured = useRef(false);\n  // Width of the label, capped by the room next to the spinner – a label that doesn't fit ends in \"…\"\n  const fit = useRef(() => {});\n  fit.current = () => {\n    const r = root.current;\n    const b = box.current;\n    const c = current.current;\n    if (!r || !b || !c) return;\n    // Layout pixels (offset/scroll/client), so it stays right inside a CSS zoom or transform (+1 covers rounding)\n    const full = c.scrollWidth + 1;\n    const parent = r.parentElement;\n    // Measured with the box at the label's full width – a parent that wraps around the pill grows with it, so only a\n    // container that's really too narrow cuts the label. Without a transition, so the pill still glides from where it was.\n    const was = { width: b.style.width, transition: b.style.transition };\n    b.style.transition = \"none\";\n    b.style.width = `${full}px`;\n    const pad = parent ? getComputedStyle(parent) : null;\n    const inner = parent && pad ? parent.clientWidth - parseFloat(pad.paddingLeft) - parseFloat(pad.paddingRight) : 0;\n    const room = parent ? Math.floor(inner - (r.offsetWidth - b.offsetWidth)) : full;\n    b.style.width = was.width;\n    b.getBoundingClientRect();\n    b.style.transition = was.transition;\n    const cut = room > 0 && full > room;\n    r.toggleAttribute(\"data-cut\", cut);\n    b.style.width = `${cut ? room : full}px`;\n  };\n  useLayoutEffect(() => {\n    const b = box.current;\n    if (!b) return;\n    if (!measured.current) b.style.transition = \"none\";\n    fit.current();\n    if (!measured.current) {\n      b.getBoundingClientRect();\n      b.style.transition = \"\";\n      measured.current = true;\n    }\n  }, [shown.key]);\n  // A container that narrows or widens gets the label fitted again\n  useEffect(() => {\n    const parent = root.current?.parentElement;\n    if (!parent || typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(() => fit.current());\n    ro.observe(parent);\n    return () => ro.disconnect();\n  }, []);\n\n  return (\n    <span\n      ref={root}\n      className={[\"ui-tlc\", className].filter(Boolean).join(\" \")}\n      data-size={size}\n      data-variant={variant}\n      data-done={done || undefined}\n      role=\"status\"\n      aria-live=\"polite\"\n    >\n      {/* React 19 hoists this into <head> once, no matter how many loaders render */}\n      <style href=\"ui-thinking-loader-clean\" precedence=\"default\">\n        {css}\n      </style>\n\n      <span className=\"ui-tlc__icon\" aria-hidden=\"true\">\n        <svg className=\"ui-tlc__spinner\" viewBox=\"0 0 16 16\">\n          {RAYS.map((i) => (\n            <line\n              key={i}\n              x1=\"8\"\n              y1=\"1.75\"\n              x2=\"8\"\n              y2=\"4.75\"\n              transform={`rotate(${i * 45} 8 8)`}\n              style={{ animationDelay: `${(i - 8) * 100}ms` }}\n            />\n          ))}\n        </svg>\n        <svg className=\"ui-tlc__check\" viewBox=\"0 0 16 16\">\n          <path d=\"M3.5 8.4 6.6 11.3 12.5 4.9\" pathLength={1} />\n        </svg>\n      </span>\n\n      <span ref={box} className=\"ui-tlc__text\">\n        {leaving && (\n          <span\n            key={leaving.key}\n            className=\"ui-tlc__label ui-tlc__label--out\"\n            aria-hidden=\"true\"\n            dir=\"auto\"\n            onAnimationEnd={() => setLeaving(null)}\n          >\n            {leaving.text}\n          </span>\n        )}\n        <span\n          key={shown.key}\n          ref={current}\n          className={[\"ui-tlc__label\", shown.key > 0 && \"ui-tlc__label--in\", !done && \"ui-tlc__shine\"].filter(Boolean).join(\" \")}\n          data-text={shown.text}\n          title={shown.text}\n          dir=\"auto\"\n        >\n          {shown.text}\n        </span>\n      </span>\n    </span>\n  );\n}\n\nconst css = /* css */ `\n.ui-tlc {\n  --ui-tlc-ink: var(--aui-ink, rgba(0, 0, 0, 0.88));\n  --ui-tlc-text: var(--aui-muted, rgba(0, 0, 0, 0.61));\n  --ui-tlc-accent: rgba(0, 96, 226, 0.765);\n  --ui-tlc-bg: var(--aui-surface, #ffffff);\n  --ui-tlc-shadow: var(--aui-shadow-card, 0 0 0 0.5px rgba(0, 0, 0, 0.06), 0 1px 1px -1px rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.05));\n  --ui-tlc-shine: rgba(255, 255, 255, 0.82);\n  --ui-tlc-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n  --ui-tlc-h: 32px;\n  --ui-tlc-font: 13px;\n  --ui-tlc-lh: 20px;\n  --ui-tlc-icon: 16px;\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  box-sizing: border-box;\n  height: var(--ui-tlc-h);\n  padding: 0 10px 0 8px;\n  border-radius: calc(var(--aui-radius, 7px) + 1px);\n  background: var(--ui-tlc-bg);\n  box-shadow: var(--ui-tlc-shadow);\n  color: var(--ui-tlc-text);\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: var(--ui-tlc-font);\n  font-weight: 500;\n  line-height: var(--ui-tlc-lh);\n  letter-spacing: -0.003em;\n  max-width: 100%;\n  vertical-align: middle;\n  -webkit-font-smoothing: antialiased;\n}\n.ui-tlc[data-size=\"sm\"] {\n  --ui-tlc-h: 26px;\n  --ui-tlc-font: 12px;\n  --ui-tlc-lh: 16px;\n  --ui-tlc-icon: 14px;\n  gap: 5px;\n  padding: 0 8px 0 6px;\n  border-radius: calc(var(--aui-radius, 7px) - 1px);\n}\n.ui-tlc[data-variant=\"plain\"] {\n  height: auto;\n  padding: 0;\n  background: none;\n  box-shadow: none;\n}\n\n/* Spinner and check share one square and swap with a little scale */\n.ui-tlc__icon {\n  position: relative;\n  flex: none;\n  width: var(--ui-tlc-icon);\n  height: var(--ui-tlc-icon);\n}\n.ui-tlc__icon svg {\n  position: absolute;\n  inset: 0;\n  width: 100%;\n  height: 100%;\n  overflow: visible;\n  transition: opacity 240ms ease, transform 420ms var(--ui-tlc-ease);\n}\n/* Thin rays in the accent blue – they fade one after another, so the spinner turns without rotating */\n.ui-tlc__spinner line {\n  stroke: var(--ui-tlc-accent);\n  stroke-width: 1.1;\n  stroke-linecap: round;\n  opacity: 0.2;\n  animation: ui-tlc-ray 800ms linear infinite;\n}\n.ui-tlc__check {\n  opacity: 0;\n  transform: scale(0.6);\n  fill: none;\n  stroke: var(--ui-tlc-accent);\n  stroke-width: 1.2;\n  stroke-linecap: round;\n  stroke-linejoin: round;\n}\n.ui-tlc__check path {\n  stroke-dasharray: 1;\n  stroke-dashoffset: 1;\n}\n.ui-tlc[data-done] .ui-tlc__spinner {\n  opacity: 0;\n  transform: scale(0.6) rotate(-45deg);\n}\n.ui-tlc[data-done] .ui-tlc__check {\n  opacity: 1;\n  transform: none;\n}\n.ui-tlc[data-done] .ui-tlc__check path {\n  animation: ui-tlc-draw 360ms var(--ui-tlc-ease) 120ms forwards;\n}\n\n/* The text box glides to the width of the incoming label; labels roll through it */\n/* min-width 0: in a narrow container the box gives way and the label ends in \"…\" */\n.ui-tlc__text {\n  position: relative;\n  display: block;\n  min-width: 0;\n  height: var(--ui-tlc-lh);\n  overflow: hidden;\n  transition: width 460ms var(--ui-tlc-ease);\n}\n.ui-tlc__label {\n  position: absolute;\n  top: 0;\n  left: 0;\n  line-height: var(--ui-tlc-lh);\n  white-space: nowrap;\n}\n.ui-tlc[data-cut] .ui-tlc__label {\n  max-width: 100%;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n.ui-tlc[data-done] .ui-tlc__label {\n  color: var(--ui-tlc-ink);\n}\n.ui-tlc__label--in {\n  animation: ui-tlc-in 460ms var(--ui-tlc-ease) both;\n}\n.ui-tlc__label--out {\n  animation: ui-tlc-out 320ms var(--ui-tlc-ease) both;\n}\n\n/* A darker band sweeping through the gray label while it works */\n.ui-tlc__shine::after {\n  content: attr(data-text);\n  content: attr(data-text) / \"\";\n  position: absolute;\n  inset: 0;\n  white-space: nowrap;\n  color: transparent;\n  background: linear-gradient(90deg, transparent 38%, var(--ui-tlc-shine) 50%, transparent 62%) 100% 0 / 300% 100% no-repeat;\n  -webkit-background-clip: text;\n  background-clip: text;\n  animation: ui-tlc-sweep 1.6s linear infinite;\n  pointer-events: none;\n}\n\n@keyframes ui-tlc-ray {\n  0% { opacity: 1; }\n  100% { opacity: 0.2; }\n}\n@keyframes ui-tlc-in {\n  from { opacity: 0; transform: translateY(70%); filter: blur(3px); }\n  to { opacity: 1; transform: none; filter: none; }\n}\n@keyframes ui-tlc-out {\n  from { opacity: 1; transform: none; filter: none; }\n  to { opacity: 0; transform: translateY(-70%); filter: blur(3px); }\n}\n@keyframes ui-tlc-draw {\n  to { stroke-dashoffset: 0; }\n}\n@keyframes ui-tlc-sweep {\n  from { background-position: 100% 0; }\n  to { background-position: 0% 0; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-tlc *,\n  .ui-tlc *::after {\n    animation-duration: 1ms !important;\n    animation-delay: 0ms !important;\n    animation-iteration-count: 1 !important;\n    transition-duration: 1ms !important;\n  }\n  .ui-tlc__spinner line { opacity: 0.6; }\n  .ui-tlc__shine::after { display: none; }\n  .ui-tlc__check path { stroke-dashoffset: 0; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-tlc {\n  --ui-tlc-ink: var(--aui-ink, rgba(255, 255, 255, 0.9));\n  --ui-tlc-text: var(--aui-muted, rgba(255, 255, 255, 0.62));\n  --ui-tlc-accent: var(--aui-blue, #4fb0fe);\n  --ui-tlc-bg: var(--aui-surface, #1c1c1e);\n  --ui-tlc-shadow: var(--aui-shadow-card, 0 0 0 0.5px rgba(255, 255, 255, 0.1), 0 1px 2px rgba(0, 0, 0, 0.4));\n  --ui-tlc-shine: #ffffff;\n}\n`;\n"}]}