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.
import { ThinkingIndicator } from "@/components/ballmac/thinking-indicator"
export default function ThinkingIndicatorDemo() {
return (
<div className="w-full max-w-sm rounded-xl border bg-card px-4 py-3.5">
<ThinkingIndicator
variant="shimmer"
showTimer
label={["Reading the project files", "Comparing two approaches", "Drafting a reply"]}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/thinking-indicatorInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/thinking-indicator.tsx// Ballmac UI: Thinking Indicator. https://ui.ballmac.com/components/thinking-indicator "use client" import * as React from "react" import { AnimatePresence, motion, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type ThinkingIndicatorProps = Omit<React.ComponentProps<"div">, "children"> & { /** 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. */ variant?: "dots" | "bars" | "wave" | "shimmer" /** One label, or several that take turns, such as "Reading the files" then "Drafting a reply". */ label?: string | string[] /** Milliseconds each label stays before the next one. */ interval?: number /** Shows a running timer such as "12s" or "1:05". */ showTimer?: boolean /** Elapsed seconds. When set the timer follows this value instead of counting by itself. */ elapsed?: number /** What screen readers hear when thinking starts. The cycling labels are visual only, so a long task does not chatter. */ statusLabel?: string /** Text size of the label. */ size?: "sm" | "default" } function formatElapsed(total: number) { const s = Math.max(0, Math.floor(total)) if (s < 60) return `${s}s` return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}` } function Dots({ reduce }: { reduce: boolean | null }) { return ( <span aria-hidden="true" className="flex h-4 items-center gap-1"> {[0, 1, 2].map((i) => ( <motion.span key={i} className="size-1.5 rounded-full bg-foreground/70" animate={reduce ? { opacity: 0.6 } : { y: [0, -4, 0], opacity: [0.35, 1, 0.35] }} transition={reduce ? undefined : { duration: 0.9, repeat: Infinity, delay: i * 0.15, ease: "easeInOut" }} /> ))} </span> ) } function Bars({ reduce }: { reduce: boolean | null }) { const heights = [0.45, 0.85, 0.6, 1, 0.5] return ( <span aria-hidden="true" className="flex h-4 items-center gap-[3px]"> {heights.map((h, i) => ( <motion.span key={i} className="block w-[3px] origin-center rounded-full bg-foreground/70" style={{ height: "100%" }} initial={false} animate={reduce ? { scaleY: h } : { scaleY: [h * 0.45, 1, h * 0.45] }} transition={reduce ? undefined : { duration: 1.1, repeat: Infinity, delay: i * 0.12, ease: "easeInOut" }} /> ))} </span> ) } function Wave({ reduce }: { reduce: boolean | null }) { return ( <svg aria-hidden="true" viewBox="0 0 48 16" className="h-4 w-12 overflow-hidden text-foreground/70"> {[0, 1, 2].map((i) => ( <g key={i} transform={`translate(0 ${(i - 1) * 1.5})`}> <motion.path d="M0 8 Q 6 1, 12 8 T 24 8 T 36 8 T 48 8 T 60 8 T 72 8" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" opacity={1 - i * 0.32} animate={reduce ? undefined : { x: [0, -24] }} transition={reduce ? undefined : { duration: 1.6 + i * 0.4, repeat: Infinity, ease: "linear" }} /> </g> ))} </svg> ) } function ThinkingIndicator({ variant = "dots", label, interval = 2600, showTimer = false, elapsed, statusLabel, size = "default", className, ...props }: ThinkingIndicatorProps) { const msg = useMessages() label ??= msg("thinking-indicator.label", "Thinking") statusLabel ??= msg("thinking-indicator.statusLabel", "Assistant is thinking") const reduce = useReducedMotion() const labels = React.useMemo(() => (Array.isArray(label) ? label : [label]), [label]) const [index, setIndex] = React.useState(0) const [seconds, setSeconds] = React.useState(0) const controlled = elapsed !== undefined React.useEffect(() => { if (labels.length < 2) return const id = setInterval(() => setIndex((i) => (i + 1) % labels.length), interval) return () => clearInterval(id) }, [labels.length, interval]) React.useEffect(() => { if (!showTimer || controlled) return const id = setInterval(() => setSeconds((s) => s + 1), 1000) return () => clearInterval(id) }, [showTimer, controlled]) const current = labels[Math.min(index, labels.length - 1)] ?? "" const shown = controlled ? elapsed : seconds const textClass = size === "sm" ? "text-xs" : "text-sm" const text = ( <span className={cn("relative inline-grid overflow-hidden", textClass)} aria-hidden="true"> <AnimatePresence mode="wait" initial={false}> <motion.span key={current} className={cn( "block whitespace-nowrap", variant === "shimmer" && !reduce ? "bg-clip-text text-transparent" : "text-muted-foreground" )} style={ variant === "shimmer" && !reduce ? { backgroundImage: "linear-gradient(90deg, var(--muted-foreground) 35%, var(--foreground) 50%, var(--muted-foreground) 65%)", backgroundSize: "250% 100%", } : undefined } initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }} animate={ variant === "shimmer" && !reduce ? { opacity: 1, y: 0, backgroundPosition: ["100% 0%", "0% 0%"] } : { opacity: 1, y: 0 } } exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }} transition={{ duration: 0.22, backgroundPosition: variant === "shimmer" ? { duration: 1.8, repeat: Infinity, ease: "linear" } : undefined, }} > {current} </motion.span> </AnimatePresence> </span> ) return ( <div data-slot="thinking-indicator" data-variant={variant} role="status" className={cn("inline-flex items-center gap-2.5", className)} {...props} > {variant === "dots" && <Dots reduce={reduce} />} {variant === "bars" && <Bars reduce={reduce} />} {variant === "wave" && <Wave reduce={reduce} />} {text} {showTimer && ( <span aria-hidden="true" className="font-mono text-[11px] text-muted-foreground tabular-nums before:me-2 before:text-border before:content-['·']" > {formatElapsed(shown)} </span> )} <span className="sr-only">{statusLabel}</span> </div> ) } export { ThinkingIndicator, formatElapsed, type ThinkingIndicatorProps }Update the import paths to match your project setup.
Usage
import { ThinkingIndicator, formatElapsed } from "@/components/ballmac/thinking-indicator"The full example is in the Code tab above.
Examples
Four animations
import { ThinkingIndicator } from "@/components/ballmac/thinking-indicator"
const variants = ["dots", "bars", "wave", "shimmer"] as const
export default function ThinkingIndicatorVariants() {
return (
<div className="grid w-full max-w-md gap-2 sm:grid-cols-2">
{variants.map((variant) => (
<div key={variant} className="flex items-center justify-between gap-3 rounded-xl border bg-card px-4 py-3">
<ThinkingIndicator variant={variant} label="Thinking" statusLabel={`Assistant is thinking (${variant})`} />
<span className="font-mono text-xs text-muted-foreground">{variant}</span>
</div>
))}
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
variantAnimation 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. | "dots" | "bars" | "wave" | "shimmer" | "dots" |
labelOne label, or several that take turns, such as "Reading the files" then "Drafting a reply". | string | string[] | — |
intervalMilliseconds each label stays before the next one. | number | 2600 |
showTimerShows a running timer such as "12s" or "1:05". | boolean | false |
elapsedElapsed seconds. When set the timer follows this value instead of counting by itself. | number | — |
statusLabelWhat screen readers hear when thinking starts. The cycling labels are visual only, so a long task does not chatter. | string | — |
sizeText size of the label. | "sm" | "default" | "default" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | role=status announces statusLabel once; cycling phrases and the timer are visual only |
| Reduced motion | Animations stop, phrases cross-fade in place |
Use with AI
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. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Thinking Indicator (@ballmac/thinking-indicator) to this project with the shadcn MCP, then use it where it fits.
Use it for
- The gap between sending a message and the first streamed token
- A long agent task where the phrase changes with the current step
Not for
- Showing the model's reasoning text; use reasoning-disclosure
- Progress with a known end; use progress or agent-plan
Registry JSON: https://ui.ballmac.com/r/thinking-indicator.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
AI Message
A chat message with user, assistant and system roles: right-aligned user bubbles, full-width assistant prose, an avatar slot, hover-revealed actions and a hydration-safe timestamp.
Reasoning Disclosure
A collapsible 'thinking' panel for model reasoning: shows a shimmering 'Thinking…' while streaming, opens automatically, then collapses to 'Thought for 12s' when done.
AI Orb
A glass orb of drifting theme-colored light that shows whether the assistant is idle, listening, thinking or speaking, and swells with a live voice level.
Agent Plan
A live task plan for agents: a vertical timeline with pending, running, done, failed and skipped steps, nested substeps, expandable output, segmented progress and retry on failure.