Loaders & Skeletons

Dots Typing

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.

Install

npx shadcn@latest add @paragon/dots-typing

dots-typing.tsx

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