A small working pill for AI replies: an eight-ray spinner and a shimmering label that rolls through what the assistant is doing, then settles on a check. Heard once by screen readers; still with reduced motion.
What changed
- 1.0.1 Oct 25, 2026A new phase fades in from a soft grey instead of from nothing, so the label never disappears between phases.
- 1.0.0 Oct 25, 2026First release.
Which CRM fits a 10-person sales team best?
"use client"
import { ThinkingLoader } from "@/components/ballmac/thinking-loader"
export default function ThinkingLoaderDemo() {
return (
<div className="grid w-full max-w-md gap-3">
<p className="bg-foreground text-background ms-auto max-w-[85%] rounded-2xl rounded-ee-md px-4 py-2.5 text-sm">Which CRM fits a 10-person sales team best?</p>
<ThinkingLoader className="justify-self-start" phases={["Thinking", "Searching the web", "Reading 6 sources", "Comparing pricing", "Writing the answer"]} />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/thinking-loaderInstall the dependencies.
$ pnpm add lucide-react motionAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18n @ballmac/motion-presetsCopy the source into your project.
components/ballmac/thinking-loader.tsx// Ballmac UI: Thinking Loader. https://ui.ballmac.com/components/thinking-loader "use client" import * as React from "react" import { Check } from "lucide-react" import { motion } from "motion/react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" import { useLoopsPaused } from "@/lib/ballmac/motion" type ThinkingLoaderProps = Omit<React.ComponentProps<"div">, "children"> & { /** What the assistant is doing, in order. The label rolls through them. Defaults to four general phases. */ phases?: string[] /** How long each phase shows, in milliseconds. */ interval?: number /** Turns the pill into a finished state: a check and `doneLabel`. */ done?: boolean /** The text of the finished state, for example "Answered with 3 sources". */ doneLabel?: string } const RAYS = Array.from({ length: 8 }, (_, i) => i) /** * A small working pill for an AI reply: an eight-ray spinner and a shimmering label that rolls through what the assistant is doing, then settles on a check. * Screen readers hear "Working" once and the finished text when it ends, not every phase. With reduced motion (or the theme's loops off) nothing spins or shimmers. */ function ThinkingLoader({ phases, interval = 1800, done = false, doneLabel, className, ...props }: ThinkingLoaderProps) { const msg = useMessages() const paused = useLoopsPaused() const list = phases && phases.length ? phases : [msg("thinking-loader.p1", "Thinking"), msg("thinking-loader.p2", "Searching the web"), msg("thinking-loader.p3", "Reading sources"), msg("thinking-loader.p4", "Writing the answer")] const [i, setI] = React.useState(0) React.useEffect(() => { if (done || list.length < 2) return const t = setInterval(() => setI((n) => (n + 1) % list.length), interval) return () => clearInterval(t) }, [done, list.length, interval]) const text = done ? (doneLabel ?? msg("thinking-loader.done", "Done")) : list[i % list.length] return ( <div data-slot="thinking-loader" data-done={done || undefined} className={cn("bg-card text-card-foreground inline-flex items-center gap-2 rounded-full border py-1.5 ps-2.5 pe-3.5 text-[13px] shadow-xs", className)} {...props}> <span aria-hidden="true" className="flex size-4 items-center justify-center"> {done ? ( <Check className="size-3.5" /> ) : ( <svg viewBox="0 0 16 16" className={cn("size-4", !paused && "animate-[spin_0.9s_steps(8)_infinite]")}> {RAYS.map((r) => ( <rect key={r} x="7.25" y="1" width="1.5" height="4" rx="0.75" fill="currentColor" opacity={0.2 + (r / 8) * 0.8} transform={`rotate(${r * 45} 8 8)`} /> ))} </svg> )} </span> <span role="status" className="sr-only"> {done ? text : msg("thinking-loader.working", "Working")} </span> <motion.span aria-hidden="true" key={text} initial={paused ? false : { opacity: 0.25, y: 3 }} animate={paused ? undefined : { opacity: 1, y: 0 }} transition={{ duration: 0.2 }} className="relative inline-block" > <span className={cn(done ? "text-foreground" : "text-muted-foreground")}>{text}</span> {!done && !paused && ( <motion.span aria-hidden="true" className="absolute inset-0 bg-clip-text text-transparent [background-image:linear-gradient(100deg,transparent_35%,var(--foreground)_50%,transparent_65%)] [background-size:250%_100%]" initial={{ backgroundPositionX: "150%" }} animate={{ backgroundPositionX: "-50%" }} transition={{ duration: 1.6, repeat: Infinity, ease: "linear" }} > {text} </motion.span> )} </motion.span> </div> ) } export { ThinkingLoader, type ThinkingLoaderProps }Update the import paths to match your project setup.
Usage
import { ThinkingLoader } from "@/components/ballmac/thinking-loader"The full example is in the Code tab above.
Examples
Finished
"use client"
import { ThinkingLoader } from "@/components/ballmac/thinking-loader"
export default function ThinkingLoaderDone() {
return <ThinkingLoader done doneLabel="Answered with 6 sources" />
}API reference
| Prop | Type | Default |
|---|---|---|
phasesWhat the assistant is doing, in order. The label rolls through them. Defaults to four general phases. | string[] | — |
intervalHow long each phase shows, in milliseconds. | number | 1800 |
doneTurns the pill into a finished state: a check and `doneLabel`. | boolean | false |
doneLabelThe text of the finished state, for example "Answered with 3 sources". | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen reader | A polite status says Working once, and the finished text when done; the rolling phases are hidden from assistive technology |
| Reduced motion | The spinner and the shimmer hold still; the label still changes |
Use with AI
phases is string[] (what the assistant is doing, in order; defaults to four general ones), interval in ms (default 1800), done turns it into a check plus doneLabel. role=status announces Working once and the finished text when done, not each phase; the spinner and shimmer stop with reduced motion or when the theme turns loops off. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Thinking Loader (@ballmac/thinking-loader) to this project with the shadcn MCP, then use it where it fits.
Use it for
- The wait before and during an AI reply
- Showing which step an agent is on without a progress bar
Not for
- A real, measurable progress bar; use progress
- A page or block that is loading; use skeleton or state-view
Registry JSON: https://ui.ballmac.com/r/thinking-loader.json
Credits
Free to use in personal and commercial projects.
Pairs well with
AI Chat
The layout for a chat UI: a message log that sticks to the bottom while replies stream unless the reader scrolls up, a jump-to-latest button, an empty state with suggestions and a footer.
Streaming Text
Text that arrives progressively, with a blinking caret while streaming, preserved whitespace, aria-busy for screen readers and an optional typewriter reveal for demos.
Agent Lanes
A swimlane timeline for multi-agent runs: one lane per agent, steps coloured by what they are doing, dashed handoffs between lanes, a Now line and a detail panel, all reachable with the arrow keys.
Run Replay
Transport controls for replaying a recorded agent run: play and pause, a scrubber with marker dots, step to the previous or next marker, and 0.5 to 4 times speed. It owns the clock so one control can drive a trace, lanes or a log.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.