{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "thinking-indicator",
  "title": "Thinking Indicator",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A waiting indicator for assistants with four animations (dots, bars, wave, shimmer), labels that take turns, an optional timer and one calm announcement for screen readers.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/thinking-indicator.tsx",
      "content": "// Ballmac UI: Thinking Indicator. https://ui.ballmac.com/components/thinking-indicator\n\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype ThinkingIndicatorProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Animation shown beside the text. \"dots\" bounces three dots, \"bars\" is an equalizer, \"wave\" is a soft sine ribbon and \"shimmer\" sweeps light across the text only. */\n  variant?: \"dots\" | \"bars\" | \"wave\" | \"shimmer\"\n  /** One label, or several that take turns, such as \"Reading the files\" then \"Drafting a reply\". */\n  label?: string | string[]\n  /** Milliseconds each label stays before the next one. */\n  interval?: number\n  /** Shows a running timer such as \"12s\" or \"1:05\". */\n  showTimer?: boolean\n  /** Elapsed seconds. When set the timer follows this value instead of counting by itself. */\n  elapsed?: number\n  /** What screen readers hear when thinking starts. The cycling labels are visual only, so a long task does not chatter. */\n  statusLabel?: string\n  /** Text size of the label. */\n  size?: \"sm\" | \"default\"\n}\n\nfunction formatElapsed(total: number) {\n  const s = Math.max(0, Math.floor(total))\n  if (s < 60) return `${s}s`\n  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`\n}\n\nfunction Dots({ reduce }: { reduce: boolean | null }) {\n  return (\n    <span aria-hidden=\"true\" className=\"flex h-4 items-center gap-1\">\n      {[0, 1, 2].map((i) => (\n        <motion.span\n          key={i}\n          className=\"size-1.5 rounded-full bg-foreground/70\"\n          animate={reduce ? { opacity: 0.6 } : { y: [0, -4, 0], opacity: [0.35, 1, 0.35] }}\n          transition={reduce ? undefined : { duration: 0.9, repeat: Infinity, delay: i * 0.15, ease: \"easeInOut\" }}\n        />\n      ))}\n    </span>\n  )\n}\n\nfunction Bars({ reduce }: { reduce: boolean | null }) {\n  const heights = [0.45, 0.85, 0.6, 1, 0.5]\n  return (\n    <span aria-hidden=\"true\" className=\"flex h-4 items-center gap-[3px]\">\n      {heights.map((h, i) => (\n        <motion.span\n          key={i}\n          className=\"block w-[3px] origin-center rounded-full bg-foreground/70\"\n          style={{ height: \"100%\" }}\n          initial={false}\n          animate={reduce ? { scaleY: h } : { scaleY: [h * 0.45, 1, h * 0.45] }}\n          transition={reduce ? undefined : { duration: 1.1, repeat: Infinity, delay: i * 0.12, ease: \"easeInOut\" }}\n        />\n      ))}\n    </span>\n  )\n}\n\nfunction Wave({ reduce }: { reduce: boolean | null }) {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 48 16\" className=\"h-4 w-12 overflow-hidden text-foreground/70\">\n      {[0, 1, 2].map((i) => (\n        <g key={i} transform={`translate(0 ${(i - 1) * 1.5})`}>\n          <motion.path\n            d=\"M0 8 Q 6 1, 12 8 T 24 8 T 36 8 T 48 8 T 60 8 T 72 8\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            opacity={1 - i * 0.32}\n            animate={reduce ? undefined : { x: [0, -24] }}\n            transition={reduce ? undefined : { duration: 1.6 + i * 0.4, repeat: Infinity, ease: \"linear\" }}\n          />\n        </g>\n      ))}\n    </svg>\n  )\n}\n\nfunction ThinkingIndicator({\n  variant = \"dots\",\n  label,\n  interval = 2600,\n  showTimer = false,\n  elapsed,\n  statusLabel,\n  size = \"default\",\n  className,\n  ...props\n}: ThinkingIndicatorProps) {\n  const msg = useMessages()\n  label ??= msg(\"thinking-indicator.label\", \"Thinking\")\n  statusLabel ??= msg(\"thinking-indicator.statusLabel\", \"Assistant is thinking\")\n  const reduce = useReducedMotion()\n  const labels = React.useMemo(() => (Array.isArray(label) ? label : [label]), [label])\n  const [index, setIndex] = React.useState(0)\n  const [seconds, setSeconds] = React.useState(0)\n  const controlled = elapsed !== undefined\n\n  React.useEffect(() => {\n    if (labels.length < 2) return\n    const id = setInterval(() => setIndex((i) => (i + 1) % labels.length), interval)\n    return () => clearInterval(id)\n  }, [labels.length, interval])\n\n  React.useEffect(() => {\n    if (!showTimer || controlled) return\n    const id = setInterval(() => setSeconds((s) => s + 1), 1000)\n    return () => clearInterval(id)\n  }, [showTimer, controlled])\n\n  const current = labels[Math.min(index, labels.length - 1)] ?? \"\"\n  const shown = controlled ? elapsed : seconds\n  const textClass = size === \"sm\" ? \"text-xs\" : \"text-sm\"\n\n  const text = (\n    <span className={cn(\"relative inline-grid overflow-hidden\", textClass)} aria-hidden=\"true\">\n      <AnimatePresence mode=\"wait\" initial={false}>\n        <motion.span\n          key={current}\n          className={cn(\n            \"block whitespace-nowrap\",\n            variant === \"shimmer\" && !reduce ? \"bg-clip-text text-transparent\" : \"text-muted-foreground\"\n          )}\n          style={\n            variant === \"shimmer\" && !reduce\n              ? {\n                  backgroundImage:\n                    \"linear-gradient(90deg, var(--muted-foreground) 35%, var(--foreground) 50%, var(--muted-foreground) 65%)\",\n                  backgroundSize: \"250% 100%\",\n                }\n              : undefined\n          }\n          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}\n          animate={\n            variant === \"shimmer\" && !reduce\n              ? { opacity: 1, y: 0, backgroundPosition: [\"100% 0%\", \"0% 0%\"] }\n              : { opacity: 1, y: 0 }\n          }\n          exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}\n          transition={{\n            duration: 0.22,\n            backgroundPosition:\n              variant === \"shimmer\" ? { duration: 1.8, repeat: Infinity, ease: \"linear\" } : undefined,\n          }}\n        >\n          {current}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  )\n\n  return (\n    <div\n      data-slot=\"thinking-indicator\"\n      data-variant={variant}\n      role=\"status\"\n      className={cn(\"inline-flex items-center gap-2.5\", className)}\n      {...props}\n    >\n      {variant === \"dots\" && <Dots reduce={reduce} />}\n      {variant === \"bars\" && <Bars reduce={reduce} />}\n      {variant === \"wave\" && <Wave reduce={reduce} />}\n      {text}\n      {showTimer && (\n        <span\n          aria-hidden=\"true\"\n          className=\"font-mono text-[11px] text-muted-foreground tabular-nums before:me-2 before:text-border before:content-['·']\"\n        >\n          {formatElapsed(shown)}\n        </span>\n      )}\n      <span className=\"sr-only\">{statusLabel}</span>\n    </div>\n  )\n}\n\nexport { ThinkingIndicator, formatElapsed, type ThinkingIndicatorProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/thinking-indicator.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "loading",
      "thinking",
      "typing",
      "status"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "Show it while a reply has not started. variant picks the animation, label takes a string or an array of phrases, showTimer adds a clock. Screen readers hear statusLabel once instead of every phrase.",
      "whenToUse": [
        "The gap between sending a message and the first streamed token",
        "A long agent task where the phrase changes with the current step"
      ],
      "whenNotToUse": [
        "Showing the model's reasoning text; use reasoning-disclosure",
        "Progress with a known end; use progress or agent-plan"
      ],
      "composesWith": [
        "ai-message",
        "reasoning-disclosure",
        "ai-orb"
      ],
      "a11y": [
        {
          "keys": "Screen readers",
          "action": "role=status announces statusLabel once; cycling phrases and the timer are visual only"
        },
        {
          "keys": "Reduced motion",
          "action": "Animations stop, phrases cross-fade in place"
        }
      ],
      "customization": [
        "variant: dots | bars | wave | shimmer",
        "label as string or string[] with interval",
        "showTimer and elapsed",
        "size: sm | default"
      ]
    },
    "examples": [
      "thinking-indicator-demo",
      "thinking-indicator-variants"
    ],
    "url": "https://ui.ballmac.com/components/thinking-indicator"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}