AI Thinking Pulse
A calm breathing dot with a soft expanding ring — the app is thinking, not stuck.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · AI Thinking Pulse
*
* A calm breathing dot with a soft expanding ring, for open-ended
* AI waiting states (analyzing, generating, streaming).
*
* Self-contained: depends only on `motion` (react ships with your app).
* Works with zero props; tune via `variant`, `size`, `color`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type AiThinkingPulseProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Core dot diameter in px. */
size?: number;
/** Dot and ring color. */
color?: string;
/** Accessible status label announced to screen readers. */
label?: string;
};
type VariantConfig = {
breathScale: number;
cycleSeconds: number;
ringCount: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Breath only, no ring. For dense UI like list rows.
subtle: { breathScale: 1.04, cycleSeconds: 2.1, ringCount: 0 },
// One soft ring per cycle. The all-purpose setting.
default: { breathScale: 1.15, cycleSeconds: 1.8, ringCount: 1 },
// Two staggered rings, quicker cycle. For hero moments.
playful: { breathScale: 1.2, cycleSeconds: 1.3, ringCount: 3 },
};
export default function AiThinkingPulse({
variant = "default",
size = 12,
color = "#7C7CF0",
label = "AI is thinking",
}: AiThinkingPulseProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Reduced motion: a static dot. The status stays visible and the
// label keeps announcing it — liveness is not conveyed by motion alone.
if (reduceMotion) {
return (
<span
role="status"
aria-label={label}
style={{
display: "inline-block",
width: size,
height: size,
borderRadius: "50%",
background: color,
opacity: 0.9,
}}
/>
);
}
return (
<span
role="status"
aria-label={label}
style={{
position: "relative",
display: "inline-block",
width: size,
height: size,
}}
>
{Array.from({ length: cfg.ringCount }, (_, ringIndex) => (
<motion.span
key={ringIndex}
aria-hidden
initial={{ scale: 1, opacity: 0 }}
animate={{ scale: [1, 2.6], opacity: [0.35, 0] }}
transition={{
duration: cfg.cycleSeconds,
repeat: Infinity,
delay: (ringIndex * cfg.cycleSeconds) / cfg.ringCount / 2,
ease: "easeOut",
}}
style={{
position: "absolute",
inset: 0,
borderRadius: "50%",
border: `1.5px solid ${color}`,
}}
/>
))}
<motion.span
aria-hidden
animate={{
scale: [1, cfg.breathScale, 1],
opacity: [0.8, 1, 0.8],
}}
transition={{
duration: cfg.cycleSeconds,
repeat: Infinity,
ease: "easeInOut",
}}
style={{
position: "absolute",
inset: 0,
borderRadius: "50%",
background: color,
}}
/>
</span>
);
}About this pattern
Open-ended waiting states for AI inference: analyzing an image, generating a reply, streaming a response. A spinner promises determinate progress it can't deliver; a breathing pulse communicates "alive and working" without promising when it will finish. The core dot breathes on a slow cycle while a ring expands and fades — quiet enough to sit inside a chat bubble without demanding attention.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
Breathing dot shown while a response is being generated.
Related patterns
- Agent Step TimelineEach finished stage of an agent run ticks over and grows its connector toward the one after it.
- Retry Backoff CountdownA ring empties over the wait while the seconds count down to the next automatic attempt.
- Reasoning Steps UnfoldA collapsed trace line opens into numbered reasoning steps, each arriving as the panel grows.