A chat typing indicator: three dots pulse scale and opacity with a 120ms phase offset inside a small bubble, static at half opacity under reduced motion.
npx shadcn@latest add @paragon/dots-typing"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
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.
*
* The pulse pauses when scrolled offscreen and collapses to static dots at
* half opacity under prefers-reduced-motion.
*/
export function DotsTyping({
bubble = true,
label = "Typing",
className,
...props
}: DotsTypingProps) {
const ref = React.useRef<HTMLDivElement>(null);
const [inView, setInView] = React.useState(true);
React.useEffect(() => {
const node = ref.current;
if (!node || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(([entry]) => {
if (entry) setInView(entry.isIntersecting);
});
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
role="status"
aria-label={label}
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">{`
@keyframes pg-dots-pulse {
0%, 55%, 100% { transform: scale(0.8); opacity: 0.4; }
25% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
[data-dots] > span {
animation: none !important;
transform: none !important;
opacity: 0.5 !important;
}
}
`}</style>
<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={{
animation: `pg-dots-pulse 1.2s var(--ease-in-out) infinite`,
animationDelay: `${i * 120}ms`,
animationPlayState: inView ? "running" : "paused",
// Start in the keyframe's rest pose so paint never flashes.
transform: "scale(0.8)",
opacity: 0.4,
}}
/>
))}
</span>
</div>
);
}