{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "thinking-indicator",
  "type": "registry:ui",
  "registryDependencies": [],
  "dependencies": ["motion"],
  "devDependencies": [],
  "files": [
    {
      "path": "thinking-indicator.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type CSSProperties,\n  forwardRef,\n  type HTMLAttributes,\n  useEffect,\n  useId,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Glyph keyframes. Every path shares the same command structure\n * (M + 4 cubic curves + Z) so Motion can interpolate the `d` attribute\n * as one continuous liquid morph. The two blobs are deliberately\n * asymmetric — passing through them makes the sparkle wobble like a\n * droplet instead of collapsing on a straight line to the circle.\n */\nconst sparkle =\n  \"M 12 3 C 12.9 7.4 16.6 11.1 21 12 C 16.6 12.9 12.9 16.6 12 21 C 11.1 16.6 7.4 12.9 3 12 C 7.4 11.1 11.1 7.4 12 3 Z\";\n\nconst blobA =\n  \"M 12 4.2 C 16.8 3.4 20.6 7.2 19.8 12 C 20.6 16.4 16.4 20.6 12 19.8 C 7.8 20.6 3.4 16.8 4.2 12 C 3.4 7.6 7.2 3.4 12 4.2 Z\";\n\nconst circle =\n  \"M 12 5 C 15.87 5 19 8.13 19 12 C 19 15.87 15.87 19 12 19 C 8.13 19 5 15.87 5 12 C 5 8.13 8.13 5 12 5 Z\";\n\nconst blobB =\n  \"M 12 3.6 C 16.4 4.6 18.6 8 19.2 12 C 18.6 16.2 16.2 19.4 12 20.4 C 8 19.4 5.2 16.4 4.8 12 C 5.4 7.8 7.6 4.4 12 3.6 Z\";\n\n/** Small companion star in the top-right gap of the main sparkle. It\n *  blooms at the midpoint of the cycle — exactly when the main glyph has\n *  condensed into the circle — then fades as the sparkle re-forms. */\nconst twinkle =\n  \"M 19 2.5 C 19.18 4.32 19.68 4.82 21.5 5 C 19.68 5.18 19.18 5.68 19 7.5 C 18.82 5.68 18.32 5.18 16.5 5 C 18.32 4.82 18.82 4.32 19 2.5 Z\";\n\n/** One shared timeline for the whole glyph, so the morph, the breathing\n *  scale, and the twinkle stay choreographed. */\nconst GLYPH_CYCLE = 4;\nconst GLYPH_TIMES = [0, 0.3, 0.5, 0.7, 1];\n\ninterface ThinkingIndicatorProps extends HTMLAttributes<HTMLDivElement> {\n  /** Status words cycled through while the indicator is visible, e.g.\n   *  `[\"Thinking\", \"Reasoning\", \"Planning\"]`. Pass a single word to pin\n   *  the label without cycling. */\n  words: string[];\n  /** Milliseconds each word stays on screen before cycling. */\n  interval?: number;\n  /** Show the morphing sparkle glyph before the label. Set to `false`\n   *  for a text-only indicator (e.g. inline before a streamed reply). */\n  showIcon?: boolean;\n}\n\nconst ThinkingIndicator = forwardRef<HTMLDivElement, ThinkingIndicatorProps>(\n  ({ className, words, interval = 3200, showIcon = true, ...props }, ref) => {\n    const [index, setIndex] = useState(0);\n    const gradientId = useId();\n    // Reduced motion drops the glyph morph, the shimmer sweep, and the word\n    // cycling — a static glyph and label carry the same meaning without the\n    // movement.\n    const reduceMotion = useReducedMotion() ?? false;\n\n    const word = words.length > 0 ? words[index % words.length] : \"\";\n    const longestWord = words.reduce(\n      (a, b) => (a.length >= b.length ? a : b),\n      \"\"\n    );\n\n    useEffect(() => {\n      if (reduceMotion || words.length <= 1) {\n        return;\n      }\n      const timer = setInterval(() => {\n        setIndex((i) => (i + 1) % words.length);\n      }, interval);\n      return () => clearInterval(timer);\n    }, [reduceMotion, words.length, interval]);\n\n    return (\n      // biome-ignore lint/a11y/useSemanticElements: output is form-associated; a div with role=status is the right fit for a chat loading row.\n      <div\n        className={cn(\n          // Typography is inherited and the glyph and gap are sized in em,\n          // so the whole indicator scales with whatever font-size the\n          // consumer sets. Only the default color is opinionated.\n          \"flex items-center gap-[0.6em] text-muted-foreground\",\n          className\n        )}\n        ref={ref}\n        role=\"status\"\n        {...props}\n      >\n        {/* Static announcement — the cycling word display below is aria-hidden\n            so screen readers hear one announcement instead of one per cycle. */}\n        {words.length > 0 && <span className=\"sr-only\">{words[0]}…</span>}\n        {showIcon && (\n          /* The glyph is exactly one line box tall (1lh), so it always\n             matches the label's rendered height; the em attributes are a\n             fallback for browsers without lh support. */\n          <svg\n            aria-hidden=\"true\"\n            className=\"h-[1lh] w-[1lh] shrink-0\"\n            fill=\"none\"\n            height=\"1.5em\"\n            viewBox=\"0 0 24 24\"\n            width=\"1.5em\"\n          >\n            <defs>\n              {/* Depth without a hardcoded palette: both stops are\n                  currentColor, only the opacity ramps. */}\n              <linearGradient\n                gradientUnits=\"userSpaceOnUse\"\n                id={gradientId}\n                x1=\"5\"\n                x2=\"20\"\n                y1=\"4\"\n                y2=\"20\"\n              >\n                <stop offset=\"0\" stopColor=\"currentColor\" stopOpacity=\"1\" />\n                <stop offset=\"1\" stopColor=\"currentColor\" stopOpacity=\"0.4\" />\n              </linearGradient>\n            </defs>\n            {reduceMotion ? (\n              <>\n                <path d={sparkle} fill={`url(#${gradientId})`} />\n                <path d={twinkle} fill=\"currentColor\" opacity={0.5} />\n              </>\n            ) : (\n              <>\n                <motion.path\n                  animate={{\n                    d: [sparkle, blobA, circle, blobB, sparkle],\n                    rotate: 360,\n                    scale: [1, 0.9, 0.78, 0.9, 1],\n                  }}\n                  d={sparkle}\n                  fill={`url(#${gradientId})`}\n                  style={{\n                    transformBox: \"view-box\",\n                    transformOrigin: \"center\",\n                  }}\n                  transition={{\n                    d: {\n                      duration: GLYPH_CYCLE,\n                      ease: \"easeInOut\",\n                      repeat: Number.POSITIVE_INFINITY,\n                      times: GLYPH_TIMES,\n                    },\n                    scale: {\n                      duration: GLYPH_CYCLE,\n                      ease: \"easeInOut\",\n                      repeat: Number.POSITIVE_INFINITY,\n                      times: GLYPH_TIMES,\n                    },\n                    rotate: {\n                      duration: GLYPH_CYCLE,\n                      ease: \"linear\",\n                      repeat: Number.POSITIVE_INFINITY,\n                    },\n                  }}\n                />\n                <motion.path\n                  animate={{\n                    opacity: [0, 0, 1, 0, 0],\n                    rotate: [0, 45, 90, 135, 180],\n                    scale: [0.2, 0.5, 1, 0.5, 0.2],\n                  }}\n                  d={twinkle}\n                  fill=\"currentColor\"\n                  style={{\n                    transformBox: \"fill-box\",\n                    transformOrigin: \"center\",\n                  }}\n                  transition={{\n                    duration: GLYPH_CYCLE,\n                    ease: \"easeInOut\",\n                    repeat: Number.POSITIVE_INFINITY,\n                    times: GLYPH_TIMES,\n                  }}\n                />\n              </>\n            )}\n          </svg>\n        )}\n        {words.length > 0 && (\n          <span aria-hidden=\"true\" className=\"inline-grid overflow-hidden\">\n            {/* Invisible longest word reserves the column width so the layout\n              never shifts as words of different lengths cycle through. */}\n            <span className=\"invisible col-start-1 row-start-1\">\n              {longestWord}\n            </span>\n            {reduceMotion ? (\n              <span className=\"col-start-1 row-start-1\">{words[0]}</span>\n            ) : (\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  animate={{\n                    y: 0,\n                    opacity: 1,\n                    filter: \"blur(0px)\",\n                    backgroundPosition: \"0% center\",\n                  }}\n                  className={cn(\n                    \"col-start-1 row-start-1 bg-[length:250%_100%,auto] bg-clip-text text-transparent\",\n                    // Theme tokens, not a hardcoded palette: the resting text\n                    // is the theme's muted foreground and the sweeping sheen\n                    // is its full foreground, in light and dark alike.\n                    \"[--ti-base:var(--muted-foreground)] [--ti-sheen:var(--foreground)]\",\n                    \"[background-repeat:no-repeat,padding-box]\"\n                  )}\n                  exit={{\n                    y: \"-70%\",\n                    opacity: 0,\n                    filter: \"blur(3px)\",\n                    transition: { duration: 0.2, ease: [0.4, 0, 0.2, 1] },\n                  }}\n                  initial={{\n                    y: \"70%\",\n                    opacity: 0,\n                    filter: \"blur(3px)\",\n                    backgroundPosition: \"100% center\",\n                  }}\n                  key={word}\n                  style={\n                    {\n                      \"--ti-spread\": `${word.length * 2}px`,\n                      backgroundImage:\n                        \"linear-gradient(90deg,#0000 calc(50% - var(--ti-spread)),var(--ti-sheen),#0000 calc(50% + var(--ti-spread))), linear-gradient(var(--ti-base),var(--ti-base))\",\n                    } as CSSProperties\n                  }\n                  transition={{\n                    y: { type: \"spring\", stiffness: 420, damping: 34 },\n                    opacity: { duration: 0.28, ease: [0.4, 0, 0.2, 1] },\n                    filter: { duration: 0.28, ease: [0.4, 0, 0.2, 1] },\n                    backgroundPosition: {\n                      duration: 2,\n                      ease: \"linear\",\n                      repeat: Number.POSITIVE_INFINITY,\n                    },\n                  }}\n                >\n                  {word}\n                </motion.span>\n              </AnimatePresence>\n            )}\n          </span>\n        )}\n      </div>\n    );\n  }\n);\n\nThinkingIndicator.displayName = \"ThinkingIndicator\";\n\nexport { ThinkingIndicator };\nexport type { ThinkingIndicatorProps };\nexport default ThinkingIndicator;\n",
      "type": "registry:ui"
    }
  ],
  "title": "Thinking Indicator",
  "description": "AI loading state with a sparkle glyph that wobbles through liquid blob keyframes into a circle while a companion twinkle blooms on the same timeline, cycling status words that slide through a soft blur, a per-word shimmer sweep, layout-stable width, and reduced-motion aware behavior."
}
