loaders
Thinking Loader
A loader with a shimmering label and a live elapsed-time counter.
Thinking0s
"use client";
import * as React from "react";
import { Brain } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";
const DEFAULT_WORDS = [
"Thinking",
"Reasoning",
"Pondering",
"Analyzing",
"Considering",
"Working it out",
];
export interface ThinkingLoaderProps {
/** Words to cycle through as the label. Defaults to a set of generic synonyms. */
words?: string[];
/** How often the label changes, in ms. */
interval?: number;
className?: string;
}
export function ThinkingLoader({
words = DEFAULT_WORDS,
interval = 6000,
className,
}: ThinkingLoaderProps) {
const elapsed = useElapsedSeconds();
const word = useCyclingWord(words, interval);
return (
<div
className={cn("flex items-center gap-1 px-4 py-3 text-muted-foreground", className)}
>
<Brain className="size-3.5 shrink-0" aria-hidden />
<ShimmerWord word={word} />
<span className="ml-auto flex items-center font-mono text-xs tabular-nums">
<SlidingNumber value={elapsed} />s
</span>
</div>
);
}
function useCyclingWord(words: string[], interval: number) {
const [index, setIndex] = React.useState(0);
React.useEffect(() => {
if (words.length <= 1) return;
const id = window.setInterval(() => {
setIndex((i) => (i + 1) % words.length);
}, interval);
return () => window.clearInterval(id);
}, [words, interval]);
return words[index % words.length];
}
function useElapsedSeconds() {
const [elapsed, setElapsed] = React.useState("0");
React.useEffect(() => {
const start = Date.now();
const id = window.setInterval(() => {
setElapsed(Math.floor((Date.now() - start) / 1000).toString());
}, 1000);
return () => window.clearInterval(id);
}, []);
return elapsed;
}
function ShimmerWord({ word }: { word: string }) {
return (
<span className="relative inline-block h-[1.2em] overflow-hidden text-xs font-medium">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={word}
className="inline-block whitespace-nowrap bg-clip-text text-transparent"
style={{
backgroundImage:
"linear-gradient(90deg, var(--muted-foreground) 30%, var(--foreground) 50%, var(--muted-foreground) 70%)",
backgroundSize: "200% 100%",
}}
initial={{ y: 10, opacity: 0, backgroundPositionX: "150%" }}
animate={{ y: 0, opacity: 1, backgroundPositionX: "-50%" }}
exit={{ y: -10, opacity: 0 }}
transition={{
y: { duration: 0.08, ease: "easeOut" },
opacity: { duration: 0.08, ease: "easeOut" },
backgroundPositionX: { repeat: Infinity, repeatType: "loop", duration: 1.4, ease: "linear", repeatDelay: 0.8 },
}}
>
{word}
</motion.span>
</AnimatePresence>
</span>
);
}
/** Each digit slides up and out when it changes, and the new one slides up into place. */
function SlidingNumber({ value }: { value: string }) {
return (
<span className="inline-flex">
{value.split("").map((char, i) => (
<span key={i} className="relative inline-block h-[1.2em] w-[0.62em] overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={`${i}-${char}`}
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -10, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="absolute inset-0 flex items-center justify-center"
>
{char}
</motion.span>
</AnimatePresence>
</span>
))}
</span>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Thinking Loader
## Summary
An 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.
## When to use
- 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).
- As the "in progress" half of a working → done sequence (see Related).
## When not to use
- When you actually know a determinate progress percentage — use a progress bar instead, it's more honest and more useful.
- 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.
- More than one active at a time in the same conversation/view. One "the system is working" signal per turn.
## Anatomy
- Brain icon: a plain, solid glyph — it doesn't shimmer, so it stays legible as a fixed anchor next to the moving label.
- 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.
- Elapsed-time counter: whole seconds, counting up from 0s.
## Behavior
- Starts counting the instant work begins.
- 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.
- If real sub-step information is available ("Thinking" → "Searching" → "Writing"), drive the label from that instead of the generic cycle.
- 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.
- 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").
## Content guidelines
- Each word is one or two words, present-participle or short verb phrase, no punctuation.
- Keep it truthful: don't cycle through "Searching" unless a search is actually happening.
## Accessibility
- Wrap in an `aria-live="polite"` region so screen readers announce label changes without interrupting other content.
- Don't rely on the animated counter alone to signal "still working" for assistive tech — the live-region label carries that meaning.
- Respect `prefers-reduced-motion`: keep the counter (it's informational) but reduce or remove the shimmer motion.
## Related patterns
- Expandable Trace is the "done" counterpart — replace this component with that one the moment work completes.