{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-thinking",
  "title": "AI Thinking",
  "description": "AI loading states: shimmer text, bouncing dots, glowing orb, collapsible reasoning steps and simulated token streaming.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://trytweenly.vercel.app/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/new-york/ai-thinking/ai-thinking.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, type Transition } from \"motion/react\"\nimport { ChevronDown } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useReducedMotion } from \"@/registry/new-york/hooks/use-reduced-motion\"\n\nexport type AiThinkingVariant = \"shimmer\" | \"dots\" | \"orb\" | \"steps\" | \"stream\"\nexport type AiThinkingSize = \"sm\" | \"md\" | \"lg\"\n\nexport interface AiThinkingProps {\n  /** Visual style. Default: \"shimmer\" */\n  variant?: AiThinkingVariant\n  /** Text shown by shimmer, dots and orb, and the steps header while running. Default: \"Thinking…\" for shimmer and steps, none for dots and orb */\n  label?: string\n  /** Reasoning trace for the steps variant. Default: [\"Reading the question\", \"Searching sources\", \"Comparing results\", \"Writing the answer\"] */\n  steps?: string[]\n  /** Controlled index of the active step. Values at or past the end mark every step done. Default: undefined */\n  activeStep?: number\n  /** Advance steps on a timer when `activeStep` is undefined. Default: true */\n  auto?: boolean\n  /** Text revealed by the stream variant. Default: a short sample answer */\n  text?: string\n  /** Stream speed in characters per second. Default: 60 */\n  speed?: number\n  /** Cycle length in ms for shimmer, dots and orb; time per step for auto steps. Default: 1400 */\n  duration?: number\n  /** Highlight color for dots, orb, spinner and checks (any CSS color). Default: \"#ff4d12\" */\n  accent?: string\n  /** Overall scale. Default: \"md\" */\n  size?: AiThinkingSize\n  /** Called once the stream finishes or every step is done. */\n  onComplete?: () => void\n  /** Additional classes for the root element. Default: undefined */\n  className?: string\n}\n\nconst DEFAULT_STEPS = [\"Reading the question\", \"Searching sources\", \"Comparing results\", \"Writing the answer\"]\nconst DEFAULT_TEXT =\n  \"Motion should explain, not decorate. Start with one clear transition per interaction, keep durations under 400ms, and let springs carry the weight so every change feels physical.\"\n\nconst ease = [0.22, 1, 0.36, 1] as const\nconst inOut = [0.76, 0, 0.24, 1] as const\nconst spring: Transition = { type: \"spring\", stiffness: 450, damping: 34 }\n\nconst TEXT = { sm: \"text-xs\", md: \"text-sm\", lg: \"text-base\" } as const\nconst DOT = { sm: 5, md: 7, lg: 9 } as const\nconst ORB = { sm: 14, md: 20, lg: 28 } as const\n\nfunction Spinner({ accent, px }: { accent: string; px: number }) {\n  return (\n    <motion.svg\n      viewBox=\"0 0 16 16\"\n      width={px}\n      height={px}\n      fill=\"none\"\n      aria-hidden\n      animate={{ rotate: 360 }}\n      transition={{ duration: 0.9, repeat: Infinity, ease: \"linear\" }}\n    >\n      <circle cx=\"8\" cy=\"8\" r=\"6\" stroke=\"currentColor\" strokeOpacity={0.18} strokeWidth={2} />\n      <path d=\"M8 2a6 6 0 0 1 6 6\" stroke={accent} strokeWidth={2} strokeLinecap=\"round\" />\n    </motion.svg>\n  )\n}\n\nfunction DrawnCheck({ accent, px, instant }: { accent: string; px: number; instant: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" width={px} height={px} fill=\"none\" aria-hidden>\n      <motion.circle\n        cx=\"8\"\n        cy=\"8\"\n        r=\"7\"\n        fill={accent}\n        initial={{ scale: instant ? 1 : 0.4, opacity: instant ? 1 : 0 }}\n        animate={{ scale: 1, opacity: 1 }}\n        transition={instant ? { duration: 0 } : spring}\n        style={{ transformOrigin: \"8px 8px\" }}\n      />\n      <motion.path\n        d=\"M4.8 8.3 7 10.4l4.2-4.6\"\n        stroke=\"#fff\"\n        strokeWidth={1.8}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        initial={{ pathLength: instant ? 1 : 0 }}\n        animate={{ pathLength: 1 }}\n        transition={instant ? { duration: 0 } : { duration: 0.32, ease, delay: 0.08 }}\n      />\n    </svg>\n  )\n}\n\nexport function AiThinking({\n  variant = \"shimmer\",\n  label,\n  steps = DEFAULT_STEPS,\n  activeStep,\n  auto = true,\n  text = DEFAULT_TEXT,\n  speed = 60,\n  duration = 1400,\n  accent = \"#ff4d12\",\n  size = \"md\",\n  onComplete,\n  className,\n}: AiThinkingProps) {\n  const reduced = useReducedMotion()\n  const cycle = Math.max(200, duration) / 1000\n\n  if (variant === \"steps\") {\n    return (\n      <Steps\n        label={label ?? \"Thinking…\"}\n        steps={steps}\n        activeStep={activeStep}\n        auto={auto}\n        duration={Math.max(200, duration)}\n        accent={accent}\n        size={size}\n        reduced={reduced}\n        onComplete={onComplete}\n        className={className}\n      />\n    )\n  }\n\n  if (variant === \"stream\") {\n    return <Stream text={text} speed={speed} size={size} reduced={reduced} onComplete={onComplete} className={className} />\n  }\n\n  if (variant === \"dots\" || variant === \"orb\") {\n    const d = DOT[size]\n    const o = ORB[size]\n    return (\n      <div role=\"status\" aria-live=\"polite\" className={cn(\"inline-flex items-center gap-2.5 text-muted-foreground\", TEXT[size], className)}>\n        {variant === \"dots\" ? (\n          <span className=\"inline-flex items-end\" style={{ gap: d * 0.7, height: d * 2.6 }} aria-hidden>\n            {[0, 1, 2].map((i) => (\n              <motion.span\n                key={i}\n                className=\"block rounded-full\"\n                style={{ width: d, height: d, backgroundColor: accent }}\n                animate={reduced ? { opacity: [0.4, 1, 0.4] } : { y: [0, -d * 1.2, 0, 0], opacity: [0.55, 1, 0.55, 0.55] }}\n                transition={{ duration: cycle * 0.75, times: reduced ? undefined : [0, 0.28, 0.6, 1], repeat: Infinity, delay: i * cycle * 0.12, ease: [0.34, 1.4, 0.64, 1] }}\n              />\n            ))}\n          </span>\n        ) : (\n          <span className=\"relative inline-grid place-items-center\" style={{ width: o * 1.9, height: o * 1.9 }} aria-hidden>\n            <motion.span\n              className=\"absolute rounded-full\"\n              style={{ width: o, height: o, backgroundColor: accent }}\n              animate={reduced ? { opacity: 0.2 } : { scale: [1, 1.9], opacity: [0.35, 0] }}\n              transition={{ duration: cycle, repeat: Infinity, ease: \"easeOut\" }}\n            />\n            <motion.span\n              className=\"absolute rounded-full blur-[6px]\"\n              style={{ width: o, height: o, backgroundColor: accent }}\n              animate={reduced ? { opacity: 0.5 } : { opacity: [0.35, 0.75, 0.35] }}\n              transition={{ duration: cycle, repeat: Infinity, ease: inOut }}\n            />\n            <motion.span\n              className=\"absolute rounded-full\"\n              style={{ width: o * 0.62, height: o * 0.62, backgroundColor: accent }}\n              animate={reduced ? undefined : { scale: [1, 1.12, 1] }}\n              transition={{ duration: cycle, repeat: Infinity, ease: inOut }}\n            />\n            <motion.span\n              className=\"absolute inset-0\"\n              animate={reduced ? undefined : { rotate: 360 }}\n              transition={{ duration: cycle * 1.6, repeat: Infinity, ease: \"linear\" }}\n            >\n              <span\n                className=\"absolute left-1/2 top-0 block -translate-x-1/2 rounded-full bg-foreground\"\n                style={{ width: Math.max(3, o * 0.18), height: Math.max(3, o * 0.18) }}\n              />\n            </motion.span>\n          </span>\n        )}\n        {label ? <span>{label}</span> : <span className=\"sr-only\">Thinking</span>}\n      </div>\n    )\n  }\n\n  // Shimmer: a light sweep across glyphs\n  const content = label ?? \"Thinking…\"\n  const glyphs = Array.from(content)\n  const stagger = (cycle * 0.5) / Math.max(1, glyphs.length)\n  return (\n    <span role=\"status\" aria-live=\"polite\" aria-label={content} className={cn(\"inline-flex font-medium text-foreground\", TEXT[size], className)}>\n      {glyphs.map((g, i) => (\n        <motion.span\n          key={i}\n          aria-hidden\n          className=\"whitespace-pre\"\n          initial={{ opacity: 0.35 }}\n          animate={reduced ? { opacity: 0.6 } : { opacity: [0.35, 1, 0.35, 0.35] }}\n          transition={reduced ? { duration: 0 } : { duration: cycle, times: [0, 0.18, 0.4, 1], repeat: Infinity, delay: i * stagger, ease: \"linear\" }}\n        >\n          {g}\n        </motion.span>\n      ))}\n    </span>\n  )\n}\n\nfunction Steps({\n  label,\n  steps,\n  activeStep,\n  auto,\n  duration,\n  accent,\n  size,\n  reduced,\n  onComplete,\n  className,\n}: {\n  label: string\n  steps: string[]\n  activeStep?: number\n  auto: boolean\n  duration: number\n  accent: string\n  size: AiThinkingSize\n  reduced: boolean\n  onComplete?: () => void\n  className?: string\n}) {\n  const id = useId()\n  const key = steps.join(\"\\u0000\")\n  const [inner, setInner] = useState(0)\n  const [open, setOpen] = useState(true)\n  const [secs, setSecs] = useState<number | null>(null)\n  const [prevKey, setPrevKey] = useState(key)\n  if (prevKey !== key) {\n    setPrevKey(key)\n    setInner(0)\n    setOpen(true)\n    setSecs(null)\n  }\n\n  const controlled = activeStep !== undefined\n  const current = Math.max(0, Math.min(steps.length, controlled ? activeStep : inner))\n  const done = current >= steps.length\n\n  // Auto advance\n  useEffect(() => {\n    if (controlled || !auto || done) return\n    const t = setTimeout(() => setInner((v) => v + 1), duration)\n    return () => clearTimeout(t)\n  }, [controlled, auto, done, current, duration])\n\n  // Measure thinking time; collapse shortly after completion\n  const startRef = useRef(0)\n  const completeRef = useRef(onComplete)\n  useEffect(() => {\n    completeRef.current = onComplete\n  })\n  useEffect(() => {\n    if (!done) startRef.current = Date.now()\n  }, [done, key])\n  useEffect(() => {\n    if (!done) return\n    const fallback = (steps.length * duration) / 1000\n    const t = setTimeout(() => {\n      setSecs(Math.max(1, Math.round(startRef.current ? (Date.now() - startRef.current) / 1000 : fallback)))\n      setOpen(false)\n      completeRef.current?.()\n    }, 600)\n    return () => clearTimeout(t)\n  }, [done, key, steps.length, duration])\n\n  const fs = TEXT[size]\n  const icon = size === \"sm\" ? 14 : size === \"lg\" ? 18 : 16\n  const visible = steps.slice(0, Math.min(steps.length, current + 1))\n  const t = (tr: Transition): Transition => (reduced ? { duration: 0 } : tr)\n\n  return (\n    <div className={cn(\"w-full max-w-sm\", fs, className)}>\n      <button\n        type=\"button\"\n        onClick={() => setOpen((o) => !o)}\n        aria-expanded={open}\n        aria-controls={id}\n        className=\"group inline-flex items-center gap-1.5 rounded-md py-1 font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n      >\n        <span role=\"status\" aria-live=\"polite\" className=\"relative\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span\n              key={done && secs !== null ? \"done\" : \"run\"}\n              initial={{ opacity: 0, y: 6 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: -6 }}\n              transition={t({ duration: 0.3, ease })}\n              className=\"inline-block\"\n            >\n              {done && secs !== null ? `Thought for ${secs}s` : label}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n        <motion.span animate={{ rotate: open ? 180 : 0 }} transition={t(spring)} className=\"inline-grid\">\n          <ChevronDown className=\"size-3.5\" aria-hidden />\n        </motion.span>\n      </button>\n\n      <AnimatePresence initial={false}>\n        {open && (\n          <motion.div\n            id={id}\n            key=\"list\"\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={{ height: 0, opacity: 0 }}\n            transition={t({ duration: 0.4, ease: inOut })}\n            className=\"overflow-hidden\"\n          >\n            <ol className=\"relative mt-1.5 pl-0.5\">\n              <AnimatePresence initial={false}>\n                {visible.map((s, i) => {\n                  const isDone = i < current\n                  const last = i === visible.length - 1\n                  return (\n                    <motion.li\n                      key={`${i}-${s}`}\n                      initial={{ opacity: 0, height: 0, filter: \"blur(4px)\" }}\n                      animate={{ opacity: 1, height: \"auto\", filter: \"blur(0px)\" }}\n                      exit={{ opacity: 0, height: 0 }}\n                      transition={t({ duration: 0.45, ease })}\n                      className=\"relative flex gap-3 overflow-hidden\"\n                    >\n                      <span className=\"relative flex flex-col items-center pt-0.5\">\n                        <span className=\"grid place-items-center text-foreground\" style={{ width: icon, height: icon }}>\n                          {isDone ? <DrawnCheck accent={accent} px={icon} instant={reduced} /> : <Spinner accent={accent} px={icon} />}\n                        </span>\n                        {!last && <span aria-hidden className=\"mt-1 w-px flex-1 bg-border\" />}\n                      </span>\n                      <span className={cn(\"pb-3 leading-5 transition-colors duration-300\", isDone ? \"text-muted-foreground\" : \"text-foreground\")}>\n                        {s}\n                      </span>\n                    </motion.li>\n                  )\n                })}\n              </AnimatePresence>\n            </ol>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  )\n}\n\nfunction Stream({\n  text,\n  speed,\n  size,\n  reduced,\n  onComplete,\n  className,\n}: {\n  text: string\n  speed: number\n  size: AiThinkingSize\n  reduced: boolean\n  onComplete?: () => void\n  className?: string\n}) {\n  const [count, setCount] = useState(0)\n  const [prevText, setPrevText] = useState(text)\n  if (prevText !== text) {\n    setPrevText(text)\n    setCount(0)\n  }\n  const total = text.length\n  const shown = reduced ? total : Math.min(count, total)\n  const finished = shown >= total\n\n  useEffect(() => {\n    if (reduced || count >= total) return\n    const cps = Math.max(1, speed)\n    // Tokens arrive in uneven bursts, like a real model\n    const burst = 2 + ((count * 7) % 5)\n    const t = setTimeout(() => setCount((c) => Math.min(total, c + burst)), (burst / cps) * 1000)\n    return () => clearTimeout(t)\n  }, [count, total, speed, reduced])\n\n  const completeRef = useRef(onComplete)\n  useEffect(() => {\n    completeRef.current = onComplete\n  })\n  useEffect(() => {\n    if (!finished || !total) return\n    const t = setTimeout(() => completeRef.current?.(), 0)\n    return () => clearTimeout(t)\n  }, [finished, total, text])\n\n  // Split the revealed text into word tokens so each fades in once\n  const visible = text.slice(0, shown)\n  const tokens = visible.match(/\\S+\\s*|\\s+/g) ?? []\n\n  return (\n    <p className={cn(\"leading-relaxed text-foreground\", TEXT[size], className)} aria-live=\"polite\" aria-busy={!finished}>\n      {tokens.map((tok, i) => (\n        <motion.span\n          key={i}\n          initial={reduced ? false : { opacity: 0, filter: \"blur(3px)\" }}\n          animate={{ opacity: 1, filter: \"blur(0px)\" }}\n          transition={{ duration: 0.35, ease }}\n          className=\"whitespace-pre-wrap\"\n        >\n          {tok}\n        </motion.span>\n      ))}\n      <AnimatePresence>\n        {!finished && (\n          <motion.span\n            key=\"caret\"\n            aria-hidden\n            className=\"ml-0.5 inline-block h-[1.05em] w-[2px] translate-y-[0.18em] rounded-full bg-foreground\"\n            initial={{ opacity: 1 }}\n            animate={{ opacity: [1, 1, 0, 0] }}\n            exit={{ opacity: 0 }}\n            transition={{ duration: 0.9, times: [0, 0.5, 0.6, 1], repeat: Infinity, ease: \"linear\" }}\n          />\n        )}\n      </AnimatePresence>\n    </p>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/ai-thinking",
  "type": "registry:component"
}