Typing Users
Feedback

Typing Users

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.

Install

npx shadcn@latest add @paragon/typing-users

Also installs: avatar

typing-users.tsx

"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>
  );
}