The "Ana and 2 others are typing" row. Avatars pop in and out of the stack as people start and stop, the sentence blur-swaps when the set changes, and three dots pulse as the trailing ellipsis — pausing offscreen and in hidden tabs.
npx shadcn@latest add @paragon/typing-usersAlso installs: avatar
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Avatar, AvatarFallback } from "@/registry/paragon/ui/avatar";
import { cn } from "@/lib/utils";
const DOT_CYCLE_MS = 1200;
const DOT_STAGGER_MS = 140;
const typingStyles = `
@keyframes pg-typing-users-dot {
0%, 60%, 100% { transform: scale(0.72); opacity: 0.4; }
30% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
@keyframes pg-typing-users-dot {
0%, 60%, 100% { transform: none; opacity: 0.4; }
30% { transform: none; opacity: 0.9; }
}
}
`;
export interface TypingUsersProps extends React.ComponentProps<"div"> {
/** Display names of everyone currently typing. */
users?: string[];
/** Avatars shown before collapsing into a "+N" chip. */
maxAvatars?: number;
/** Verb phrase after the names, e.g. "typing" or "drafting a reply". */
suffix?: string;
/** Swap states instantly instead of animating. */
static?: boolean;
}
function firstName(name: string): string {
return name.trim().split(/\s+/)[0] ?? name;
}
function buildSentence(users: string[], suffix: string): string {
if (users.length === 0) return "";
if (users.length === 1) return `${firstName(users[0])} is ${suffix}`;
if (users.length === 2)
return `${firstName(users[0])} and ${firstName(users[1])} are ${suffix}`;
return `${firstName(users[0])} and ${users.length - 1} others are ${suffix}`;
}
const spring = { type: "spring", duration: 0.35, bounce: 0 } as const;
/**
* The "Ana and 2 others are typing" row. Avatars pop in and out of the
* stack via popLayout as people start and stop; the sentence blur-swaps
* whenever the set changes; three dots pulse as the trailing ellipsis.
* The stack caps at `maxAvatars` with a "+N" overflow chip. The row is a
* `role="status"` live region, dots pause offscreen and in hidden tabs,
* and reduced motion keeps only opacity.
*/
export function TypingUsers({
users = [],
maxAvatars = 3,
suffix = "typing",
static: isStatic = false,
className,
...props
}: TypingUsersProps) {
const reducedMotion = useReducedMotion();
const instant = isStatic || !!reducedMotion;
const ref = React.useRef<HTMLDivElement>(null);
const [playing, setPlaying] = React.useState(true);
// Pause the dot loop 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);
};
}, []);
const visible = users.slice(0, maxAvatars);
const overflow = users.length - visible.length;
const sentence = buildSentence(users, suffix);
const fade = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
return (
<div
ref={ref}
role="status"
data-slot="typing-users"
className={cn(
"flex h-8 items-center gap-2 text-xs text-muted-foreground",
className,
)}
{...props}
>
<style href="paragon-typing-users" precedence="paragon">
{typingStyles}
</style>
{/* Avatar stack — people pop in/out, neighbors glide over. */}
<AnimatePresence mode="popLayout" initial={false}>
{users.length > 0 && (
<motion.div
key="stack"
aria-hidden
className="flex items-center -space-x-1.5"
transition={spring}
{...fade}
>
<AnimatePresence mode="popLayout" initial={false}>
{visible.map((name) => (
<motion.span
key={name}
layout={!instant}
className="relative inline-flex"
initial={instant ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={instant ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
transition={spring}
>
<Avatar size="sm" ring>
<AvatarFallback name={name} />
</Avatar>
</motion.span>
))}
{overflow > 0 && (
<motion.span
key="overflow"
layout={!instant}
className="relative z-10 inline-flex"
initial={instant ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={instant ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
transition={spring}
>
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground tabular-nums ring-2 ring-background">
+{overflow}
</span>
</motion.span>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
{/* Sentence — blur-swaps whenever the set of names changes. */}
<AnimatePresence mode="popLayout" initial={false}>
{sentence && (
<motion.span
key={sentence}
className="whitespace-nowrap"
initial={
instant
? { opacity: 0 }
: { opacity: 0, y: 4, filter: "blur(4px)" }
}
animate={
instant
? { opacity: 1 }
: { opacity: 1, y: 0, filter: "blur(0px)" }
}
exit={
instant
? { opacity: 0 }
: { opacity: 0, y: -4, filter: "blur(4px)" }
}
transition={spring}
>
{sentence}
</motion.span>
)}
</AnimatePresence>
{/* Pulsing dots as the trailing ellipsis. */}
<AnimatePresence mode="popLayout" initial={false}>
{users.length > 0 && (
<motion.span
key="dots"
aria-hidden
layout={!instant}
className="-ml-1 flex items-center gap-0.5"
transition={spring}
{...fade}
>
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 rounded-full bg-current"
style={{
animation: `pg-typing-users-dot ${DOT_CYCLE_MS}ms var(--ease-in-out) infinite`,
// Negative delays keep the wave mid-cycle on first paint.
animationDelay: `${i * DOT_STAGGER_MS - DOT_CYCLE_MS}ms`,
animationPlayState: playing ? "running" : "paused",
}}
/>
))}
</motion.span>
)}
</AnimatePresence>
</div>
);
}