A chat typing indicator: three dots pulse scale and opacity with a 120ms phase offset inside a small bubble, pausing offscreen and softening to an opacity pulse under reduced motion.
npx shadcn@latest add @paragon/dots-typing"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
const CYCLE_MS = 1200;
const STAGGER_MS = 120;
export interface DotsTypingProps extends React.ComponentProps<"div"> {
/** Render the chat-bubble container around the dots. */
bubble?: boolean;
/** Accessible label for the indicator. */
label?: string;
}
/**
* The elevated typing indicator: three dots pulsing scale + opacity — not
* bouncing — with a 120ms phase offset between dots, inside a small chat
* bubble. Negative animation delays start the wave mid-cycle, so it is
* already breathing on first paint instead of flashing to attention.
*
* The pulse pauses when scrolled offscreen or when the tab hides, and
* softens to a slow opacity-only pulse under prefers-reduced-motion.
*/
const DOTS_KEYFRAMES = `
@keyframes pg-dots-pulse {
0%, 55%, 100% { transform: scale(0.8); opacity: 0.4; }
25% { transform: scale(1); opacity: 1; }
}
@keyframes pg-dots-fade {
0%, 55%, 100% { opacity: 0.35; }
25% { opacity: 0.8; }
}
[data-dots] > span {
animation: pg-dots-pulse ${CYCLE_MS}ms var(--ease-in-out) infinite;
}
@media (prefers-reduced-motion: reduce) {
[data-dots] > span {
animation-name: pg-dots-fade;
animation-duration: 2.2s;
}
}
`;
export function DotsTyping({
bubble = true,
label = "Typing",
className,
...props
}: DotsTypingProps) {
const ref = React.useRef<HTMLDivElement>(null);
const [playing, setPlaying] = React.useState(true);
// Pause while scrolled offscreen or while the tab is hidden.
React.useEffect(() => {
const node = ref.current;
if (!node) return;
let inView = true;
let visible = !document.hidden;
const update = () => setPlaying(inView && visible);
const observer =
typeof IntersectionObserver === "undefined"
? null
: new IntersectionObserver(([entry]) => {
if (entry) {
inView = entry.isIntersecting;
update();
}
});
observer?.observe(node);
const onVisibility = () => {
visible = !document.hidden;
update();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
observer?.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
return (
<div
ref={ref}
role="status"
data-slot="dots-typing"
className={cn(
"w-fit",
bubble && "rounded-2xl rounded-bl-md bg-muted px-3 py-2.5",
className,
)}
{...props}
>
<style href="paragon-dots-typing" precedence="paragon">
{DOTS_KEYFRAMES}
</style>
<span className="sr-only">{label}</span>
<span aria-hidden data-dots="" className="flex items-center gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1.5 rounded-full bg-muted-foreground"
style={{
// Negative delays keep the left-to-right stagger while starting
// every dot mid-cycle — no rest-pose flash on mount.
animationDelay: `${i * STAGGER_MS - CYCLE_MS}ms`,
animationPlayState: playing ? "running" : "paused",
}}
/>
))}
</span>
</div>
);
}