Dots Typing
Loaders & Skeletons

Dots Typing

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.

Install

npx shadcn@latest add @paragon/dots-typing

dots-typing.tsx

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