All patterns

Typing Indicator

Three dots rise and fall in sequence inside the bubble a reply will occupy.

socialfriendlysubtleautomatic · looping · starter · ~1.2s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

123 lines · react + motion only
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Typing Indicator
 *
 * Three dots inside the bubble the reply will occupy. They share one
 * cycle offset by a third each, so the movement reads as travelling
 * left to right rather than pulsing as a block — and the travel stays
 * small enough that the bubble itself never appears to move.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The bubble is mixed from the inherited text color, so it reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `who`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type TypingIndicatorDotsProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Person composing. Used for the caption and the status label. */
  who?: string;
  /** Hide the caption above the bubble. */
  showCaption?: boolean;
  /** Dot diameter in px. */
  size?: number;
};

type VariantConfig = {
  /** px each dot rises at the top of its cycle. */
  travel: number;
  /** Length of one full cycle. */
  cycleSeconds: number;
  /** Opacity floor between rises. */
  dim: number;
};

// No spring here on purpose: a looping indicator runs for as long as
// someone is composing, and an eased keyframe cycle is the only way to
// guarantee every repetition looks identical.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Opacity does most of the work. For a busy group thread.
  subtle: { travel: 2, cycleSeconds: 1.49, dim: 0.4 },
  // Enough travel to read across a room. All-purpose.
  default: { travel: 4, cycleSeconds: 1.2, dim: 0.3 },
  // Quicker and higher, for a one-to-one conversation.
  playful: { travel: 8, cycleSeconds: 0.92, dim: 0.2 },
};

const DOTS = [0, 1, 2];

/** Theme-adaptive neutral: mixing the text color in scope with
 *  `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function TypingIndicatorDots({
  variant = "default",
  who = "Priya Sen",
  showCaption = true,
  size = 7,
}: TypingIndicatorDotsProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const label = `${who} is typing`;

  return (
    <div style={{ display: "inline-block" }}>
      {showCaption && (
        <div style={{ fontSize: 11.5, opacity: 0.5, marginBottom: 5, marginLeft: 4 }}>
          {who}
        </div>
      )}

      <div
        role="status"
        aria-label={label}
        style={{
          display: "inline-flex",
          alignItems: "center",
          gap: size * 0.72,
          padding: `${size + 4}px ${size + 6}px`,
          borderRadius: `${size * 2.4}px ${size * 2.4}px ${size * 2.4}px ${size * 0.7}px`,
          background: tone(8),
          border: `1px solid ${tone(10)}`,
        }}
      >
        {DOTS.map((index) => (
          <motion.span
            key={index}
            aria-hidden
            // Reduced motion: three dots at rest still say "a reply is
            // coming". The cycle is what gets dropped, not the signal.
            animate={
              reduceMotion
                ? { y: 0, opacity: 0.5 }
                : { y: [0, -cfg.travel, 0], opacity: [cfg.dim, 1, cfg.dim] }
            }
            transition={
              reduceMotion
                ? { duration: 0 }
                : {
                    duration: cfg.cycleSeconds,
                    repeat: Infinity,
                    ease: "easeInOut",
                    // A third of a cycle between neighbours: the crest
                    // travels across the row instead of the three dots
                    // pulsing together.
                    delay: (index * cfg.cycleSeconds) / 3,
                  }
            }
            style={{
              width: size,
              height: size,
              borderRadius: "50%",
              background: "currentColor",
            }}
          />
        ))}
      </div>
    </div>
  );
}

About this pattern

The oldest liveness signal in messaging, and the one most often overdone. Three dots share one cycle offset by a third each, so the movement travels left to right instead of pulsing as a block, and the travel stays small enough that the bubble itself never appears to move. The indicator occupies the same slot the incoming message will land in, so nothing jumps when the words arrive.

Someone is composing a replyGroup chat activitySupport agent respondingPlaceholder for an incoming bubble

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Dana Whitfieldonline
    Morning — did the venue confirm?
    They did, contract came back signed.10:14
    Are we still on for Thursday?
    Yes — booked the room for 2pm.10:14
    Perfect. I'll bring the printouts.
    See you then.10:14
    Message
    Chat thread

    Dots sit in the slot the incoming bubble will occupy.

Related patterns