Typing Indicator
Three dots rise and fall in sequence inside the bubble a reply will occupy.
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 · Typing Indicator
*
* Three dots inside the bubble the reply will occupy. They share one
* cycle offset by a third each, so the movement reads as travelling
* left to right rather than pulsing as a block — and the travel stays
* small enough that the bubble itself never appears to move.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The bubble is mixed from the inherited text color, so it reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `who`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type TypingIndicatorDotsProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Person composing. Used for the caption and the status label. */
who?: string;
/** Hide the caption above the bubble. */
showCaption?: boolean;
/** Dot diameter in px. */
size?: number;
};
type VariantConfig = {
/** px each dot rises at the top of its cycle. */
travel: number;
/** Length of one full cycle. */
cycleSeconds: number;
/** Opacity floor between rises. */
dim: number;
};
// No spring here on purpose: a looping indicator runs for as long as
// someone is composing, and an eased keyframe cycle is the only way to
// guarantee every repetition looks identical.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Opacity does most of the work. For a busy group thread.
subtle: { travel: 2, cycleSeconds: 1.49, dim: 0.4 },
// Enough travel to read across a room. All-purpose.
default: { travel: 4, cycleSeconds: 1.2, dim: 0.3 },
// Quicker and higher, for a one-to-one conversation.
playful: { travel: 8, cycleSeconds: 0.92, dim: 0.2 },
};
const DOTS = [0, 1, 2];
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function TypingIndicatorDots({
variant = "default",
who = "Priya Sen",
showCaption = true,
size = 7,
}: TypingIndicatorDotsProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const label = `${who} is typing`;
return (
<div style={{ display: "inline-block" }}>
{showCaption && (
<div style={{ fontSize: 11.5, opacity: 0.5, marginBottom: 5, marginLeft: 4 }}>
{who}
</div>
)}
<div
role="status"
aria-label={label}
style={{
display: "inline-flex",
alignItems: "center",
gap: size * 0.72,
padding: `${size + 4}px ${size + 6}px`,
borderRadius: `${size * 2.4}px ${size * 2.4}px ${size * 2.4}px ${size * 0.7}px`,
background: tone(8),
border: `1px solid ${tone(10)}`,
}}
>
{DOTS.map((index) => (
<motion.span
key={index}
aria-hidden
// Reduced motion: three dots at rest still say "a reply is
// coming". The cycle is what gets dropped, not the signal.
animate={
reduceMotion
? { y: 0, opacity: 0.5 }
: { y: [0, -cfg.travel, 0], opacity: [cfg.dim, 1, cfg.dim] }
}
transition={
reduceMotion
? { duration: 0 }
: {
duration: cfg.cycleSeconds,
repeat: Infinity,
ease: "easeInOut",
// A third of a cycle between neighbours: the crest
// travels across the row instead of the three dots
// pulsing together.
delay: (index * cfg.cycleSeconds) / 3,
}
}
style={{
width: size,
height: size,
borderRadius: "50%",
background: "currentColor",
}}
/>
))}
</div>
</div>
);
}About this pattern
The oldest liveness signal in messaging, and the one most often overdone. Three dots share one cycle offset by a third each, so the movement travels left to right instead of pulsing as a block, and the travel stays small enough that the bubble itself never appears to move. The indicator occupies the same slot the incoming message will land in, so nothing jumps when the words arrive.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Chat thread
Dots sit in the slot the incoming bubble will occupy.