{"slug":"thinking-loader","category":"loaders","job":"oversee","added":"2026-09-11T19:59Z","title":"Thinking Loader","description":"A loader with a shimmering label and a live elapsed-time counter.","url":"https://ai-patterns.app/patterns/loaders/thinking-loader","markdown":"https://ai-patterns.app/patterns/loaders/thinking-loader.md","json":"https://ai-patterns.app/api/patterns/loaders/thinking-loader","seenIn":[{"team":"Claude","source":"https://support.claude.com/en/articles/10574485-using-extended-thinking"}],"spec":"# Thinking Loader\n\n## Summary\nAn inline status indicator shown while an agent is working on a response: a small looping glyph, a shimmering verb label, and a live elapsed-time counter. It tells the user the system is alive and roughly how long it has been going, without claiming a specific progress percentage it doesn't have.\n\n## When to use\n- Any moment an agent or background process is working and the duration is unknown or variable (LLM generation, tool calls, search, a long-running job).\n- As the \"in progress\" half of a working → done sequence (see Related).\n\n## When not to use\n- When you actually know a determinate progress percentage — use a progress bar instead, it's more honest and more useful.\n- As a permanent decoration left running after the work is actually done. The instant work finishes, replace this component with a completed-state summary; do not just freeze or hide it in place.\n- More than one active at a time in the same conversation/view. One \"the system is working\" signal per turn.\n\n## Anatomy\n- Brain icon: a plain, solid glyph — it doesn't shimmer, so it stays legible as a fixed anchor next to the moving label.\n- Shimmering label: a present-participle verb or short phrase describing the current activity, cycling through a small set of synonyms (\"Thinking\", \"Reasoning\", \"Pondering\", ...) on a timer.\n- Elapsed-time counter: whole seconds, counting up from 0s.\n\n## Behavior\n- Starts counting the instant work begins.\n- The label cycles to the next word in its list every 6 seconds, fading/sliding out the old word and in the new one — the shimmer (a single sweep, pause, repeat, not a relentless loop) communicates \"still working\" even between word changes.\n- If real sub-step information is available (\"Thinking\" → \"Searching\" → \"Writing\"), drive the label from that instead of the generic cycle.\n- The counter's changed digit animates in place (a short roll/slide) rather than the whole number re-rendering, so it doesn't read as flicker.\n- On completion, replace the whole component with a result — for an agent trace, that's typically the collapsed header of an Expandable Trace (\"Thought for 4 seconds\").\n\n## Content guidelines\n- Each word is one or two words, present-participle or short verb phrase, no punctuation.\n- Keep it truthful: don't cycle through \"Searching\" unless a search is actually happening.\n\n## Accessibility\n- Wrap in an `aria-live=\"polite\"` region so screen readers announce label changes without interrupting other content.\n- Don't rely on the animated counter alone to signal \"still working\" for assistive tech — the live-region label carries that meaning.\n- Respect `prefers-reduced-motion`: keep the counter (it's informational) but reduce or remove the shimmer motion.\n\n## Related patterns\n- **Expandable Trace** — Use it instead the moment work completes, so the loader becomes a Thought for Ns trace.\n- **Stream of Thought** — Use it instead when the reasoning text itself should stream in under the header.\n- **Tool Call Chip** — Use it instead when the agent is running a specific tool and can say which.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { Brain } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_WORDS = [\n  \"Thinking\",\n  \"Reasoning\",\n  \"Pondering\",\n  \"Analyzing\",\n  \"Considering\",\n  \"Working it out\",\n];\n\nexport interface ThinkingLoaderProps {\n  /** Words to cycle through as the label. Defaults to a set of generic synonyms. */\n  words?: string[];\n  /** How often the label changes, in ms. */\n  interval?: number;\n  className?: string;\n}\n\nexport function ThinkingLoader({\n  words = DEFAULT_WORDS,\n  interval = 6000,\n  className,\n}: ThinkingLoaderProps) {\n  const elapsed = useElapsedSeconds();\n  const word = useCyclingWord(words, interval);\n\n  return (\n    <div\n      className={cn(\"flex items-center gap-1 px-4 py-3 text-muted-foreground\", className)}\n    >\n      <Brain className=\"size-3.5 shrink-0\" aria-hidden />\n      <ShimmerWord word={word} />\n      <span className=\"ml-auto flex items-center font-mono text-xs tabular-nums\">\n        <SlidingNumber value={elapsed} />s\n      </span>\n    </div>\n  );\n}\n\nfunction useCyclingWord(words: string[], interval: number) {\n  const [index, setIndex] = React.useState(0);\n\n  React.useEffect(() => {\n    if (words.length <= 1) return;\n    const id = window.setInterval(() => {\n      setIndex((i) => (i + 1) % words.length);\n    }, interval);\n    return () => window.clearInterval(id);\n  }, [words, interval]);\n\n  return words[index % words.length];\n}\n\nfunction useElapsedSeconds() {\n  const [elapsed, setElapsed] = React.useState(\"0\");\n\n  React.useEffect(() => {\n    const start = Date.now();\n    const id = window.setInterval(() => {\n      setElapsed(Math.floor((Date.now() - start) / 1000).toString());\n    }, 1000);\n    return () => window.clearInterval(id);\n  }, []);\n\n  return elapsed;\n}\n\nfunction ShimmerWord({ word }: { word: string }) {\n  return (\n    <span className=\"relative inline-block h-[1.2em] overflow-hidden text-xs font-medium\">\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={word}\n          className=\"inline-block whitespace-nowrap bg-clip-text text-transparent\"\n          style={{\n            backgroundImage:\n              \"linear-gradient(90deg, var(--muted-foreground) 30%, var(--foreground) 50%, var(--muted-foreground) 70%)\",\n            backgroundSize: \"200% 100%\",\n          }}\n          initial={{ y: 10, opacity: 0, backgroundPositionX: \"150%\" }}\n          animate={{ y: 0, opacity: 1, backgroundPositionX: \"-50%\" }}\n          exit={{ y: -10, opacity: 0 }}\n          transition={{\n            y: { duration: 0.08, ease: \"easeOut\" },\n            opacity: { duration: 0.08, ease: \"easeOut\" },\n            backgroundPositionX: { repeat: Infinity, repeatType: \"loop\", duration: 1.4, ease: \"linear\", repeatDelay: 0.8 },\n          }}\n        >\n          {word}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** Each digit slides up and out when it changes, and the new one slides up into place. */\nfunction SlidingNumber({ value }: { value: string }) {\n  return (\n    <span className=\"inline-flex\">\n      {value.split(\"\").map((char, i) => (\n        <span key={i} className=\"relative inline-block h-[1.2em] w-[0.62em] overflow-hidden\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span\n              key={`${i}-${char}`}\n              initial={{ y: 10, opacity: 0 }}\n              animate={{ y: 0, opacity: 1 }}\n              exit={{ y: -10, opacity: 0 }}\n              transition={{ duration: 0.25, ease: \"easeOut\" }}\n              className=\"absolute inset-0 flex items-center justify-center\"\n            >\n              {char}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      ))}\n    </span>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}