Skip to content

Who is typing, in words and a dot wave, opening its own height.

01Preview

#release-4-2
  • Ben CarterMigration dry run passed on the replica.
  • Dev PatelRelease notes for 4.2 are approved.
  • Chen WeiDeploy window opens at 14:00.
Ana is typing
Message #release-4-2

Start and stop typing as a teammate

Direct message with Ana Ruiz

Can you check the invoice total?
Ana is typing

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion

03Usage

import { TypingIndicator } from "@/components/ui/typing-indicator";

// Under a channel composer.
<TypingIndicator people={typing} />

// At the end of a direct-message thread.
<TypingIndicator people={typing} variant="bubble" />

04Source

"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";

export type TypingPerson = { id: string; name: string; src?: string };

/* -------------------------------------------------------------------------------------------------
 * Words
 * -----------------------------------------------------------------------------------------------*/

const firstName = (name: string) => name.trim().split(/\s+/)[0] ?? name;

/**
 * The sentence, as parts so names can be set apart from the verb. Names are first names: in a
 * thread everyone already knows who Ana is, and the line has to fit beside a composer.
 *   1 → "Ana is typing"   2 → "Ana and Ben are typing"   3 → "Ana, Ben and Chen are typing"
 *   4+ → "Ana, Ben and 3 others are typing"
 */
export function typingParts(people: TypingPerson[]): { names: string[]; rest: number; text: string } {
  const all = people.map((p) => firstName(p.name));
  const names = all.length <= 3 ? all : all.slice(0, 2);
  const rest = all.length - names.length;
  const joined =
    rest > 0
      ? `${names.join(", ")} and ${rest} others`
      : names.length === 1
        ? names[0]
        : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
  return { names, rest, text: all.length ? `${joined} ${all.length === 1 ? "is" : "are"} typing` : "" };
}

/* -------------------------------------------------------------------------------------------------
 * Dots
 * -----------------------------------------------------------------------------------------------*/

// Continuous motion stops when nobody can see it: off screen, or the tab hidden.
function useVisible<T extends Element>() {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(true);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    let inView = true;
    const update = () => setVisible(inView && !document.hidden);
    const io = new IntersectionObserver(([entry]) => {
      inView = entry.isIntersecting;
      update();
    });
    io.observe(el);
    document.addEventListener("visibilitychange", update);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", update);
    };
  }, []);
  return [ref, visible] as const;
}

export type TypingDotsProps = React.ComponentProps<"span"> & {
  /** Dot diameter in px. */
  dot?: number;
};

/** Three dots in a wave, 150ms apart. Pauses off screen. Decorative: pair it with words. */
export function TypingDots({ dot = 4, className, style, ...rest }: TypingDotsProps) {
  const [ref, visible] = useVisible<HTMLSpanElement>();
  return (
    <span
      ref={ref}
      aria-hidden
      data-paused={visible ? undefined : ""}
      className={cn("inline-flex items-center data-paused:[&>span]:[animation-play-state:paused]", className)}
      style={{ gap: Math.max(2, Math.round(dot * 0.75)), ...style }}
      {...rest}
    >
      {[0, 1, 2].map((i) => (
        <span
          key={i}
          className="block animate-dot-wave rounded-full bg-current motion-reduce:animate-none motion-reduce:opacity-60"
          style={{ width: dot, height: dot, animationDelay: `${i * 150}ms` }}
        />
      ))}
    </span>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Faces (bubble variant)
 * -----------------------------------------------------------------------------------------------*/

const tones = ["bg-fg/[0.07] text-fg-2", "bg-fg/[0.1] text-fg-2", "bg-fg/[0.13] text-fg", "bg-fg/[0.17] text-fg", "bg-fg/[0.22] text-fg"];
function tone(key: string) {
  let h = 0x811c9dc5;
  for (let i = 0; i < key.length; i++) h = Math.imul(h ^ key.charCodeAt(i), 0x01000193);
  return tones[(h >>> 0) % tones.length];
}

function Face({ person, size, cut }: { person: TypingPerson; size: number; cut?: string }) {
  const [broken, setBroken] = useState(false);
  const letter = (Array.from(person.name.trim())[0] ?? "").toUpperCase();
  return (
    <span
      className={cn("relative grid shrink-0 place-items-center overflow-hidden rounded-full font-medium leading-none", tone(person.id))}
      style={{ width: size, height: size, fontSize: Math.round(size * 0.42), maskImage: cut, WebkitMaskImage: cut }}
    >
      {letter}
      {person.src && !broken && (
        // eslint-disable-next-line @next/next/no-img-element -- a 20px face from any host, no optimizer in the way
        <img src={person.src} alt="" width={size} height={size} onError={() => setBroken(true)} className="absolute inset-0 size-full object-cover" />
      )}
    </span>
  );
}

/* -------------------------------------------------------------------------------------------------
 * TypingIndicator
 * -----------------------------------------------------------------------------------------------*/

export type TypingIndicatorProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** Everyone typing right now, the viewer excluded. Empty collapses the row to nothing. */
  people: TypingPerson[];
  /** A line of text under a composer, or a message-shaped bubble at the end of a thread. */
  variant?: "inline" | "bubble";
  /** Announce who started typing to screen readers. Off for busy channels. */
  announce?: boolean;
};

/**
 * Says who is typing, with three dots in a wave. It opens and closes its own height so the thread
 * above never jumps, and the sentence slides to its new wording as people start and stop.
 */
export function TypingIndicator({ people, variant = "inline", announce = true, className, ...rest }: TypingIndicatorProps) {
  const reduce = useReducedMotion();
  const { names, rest: others, text } = typingParts(people);
  const active = people.length > 0;

  const height = reduce
    ? { duration: 0 }
    : { height: { duration: 0.24, ease: ease.inOut }, opacity: { duration: 0.2, ease: ease.out }, y: { duration: 0.24, ease: ease.out } };

  return (
    <div data-variant={variant} data-state={active ? "active" : "idle"} className={cn("relative", className)} {...rest}>
      {/* The live region stays in the DOM when empty, so the first "is typing" is read out. */}
      <span role="status" aria-live="polite" className="sr-only">
        {announce ? text : ""}
      </span>
      <AnimatePresence initial={false}>
        {active && (
          <motion.div
            key="row"
            aria-hidden
            className="overflow-hidden"
            // Named variants so the bubble inside can inherit them and grow with the row.
            variants={{
              hidden: { height: 0, opacity: 0, y: reduce ? 0 : 4 },
              shown: { height: "auto", opacity: 1, y: 0, transition: height },
              gone: { height: 0, opacity: 0, y: 0, transition: reduce ? { duration: 0 } : { height: { duration: 0.18, ease: ease.inOut }, opacity: { duration: 0.12 } } },
            }}
            initial="hidden"
            animate="shown"
            exit="gone"
          >
            {variant === "bubble" ? <Bubble people={people} reduce={!!reduce} /> : <Line names={names} others={others} text={text} reduce={!!reduce} />}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function Line({ names, others, text, reduce }: { names: string[]; others: number; text: string; reduce: boolean }) {
  const verb = names.length + others === 1 ? "is" : "are";
  return (
    <div className="flex h-6 min-w-0 items-center gap-2 text-[12px] text-fg-3">
      <TypingDots dot={4} className="w-5 shrink-0 justify-center text-fg-3" />
      {/* Each wording is its own layer, so a change rolls the line instead of rewriting it in place. */}
      <span className="relative grid min-w-0 flex-1 overflow-hidden">
        <AnimatePresence initial={false} mode="popLayout">
          <motion.span
            key={text}
            className="col-start-1 row-start-1 truncate"
            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(2px)" }}
            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(2px)" }}
            transition={{ duration: reduce ? 0.12 : 0.22, ease: ease.out }}
          >
            {names.map((n, i) => (
              <span key={i}>
                {i > 0 && (i === names.length - 1 && others === 0 ? " and " : ", ")}
                <span className="font-medium text-fg-2">{n}</span>
              </span>
            ))}
            {others > 0 && (
              <>
                {" and "}
                <span className="font-medium text-fg-2 tabular">{others} others</span>
              </>
            )}{" "}
            {verb} typing
          </motion.span>
        </AnimatePresence>
      </span>
    </div>
  );
}

function Bubble({ people, reduce }: { people: TypingPerson[]; reduce: boolean }) {
  const faces = people.slice(0, 2);
  const size = 22;
  const overlap = 7;
  const cut = `radial-gradient(circle at ${overlap - size / 2}px 50%, transparent ${size / 2 + 1.5}px, var(--fg) ${size / 2 + 2}px)`;
  return (
    <div className="flex items-end gap-2 pt-1.5 pb-0.5">
      <span className="flex shrink-0">
        <AnimatePresence initial={false} mode="popLayout">
          {faces.map((p, i) => (
            <motion.span
              key={p.id}
              layout={!reduce}
              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, transition: { duration: 0.12 } }}
              transition={spring.pop}
              className="relative flex"
              style={{ zIndex: 2 - i, marginLeft: i ? -overlap : 0 }}
            >
              <Face person={p} size={size} cut={i ? cut : undefined} />
            </motion.span>
          ))}
        </AnimatePresence>
      </span>
      {/* The bubble grows out of its tail corner, the way the message it stands in for will. */}
      <motion.span
        variants={{
          hidden: reduce ? {} : { scale: 0.8 },
          shown: { scale: 1, transition: reduce ? { duration: 0 } : { ...spring.snappy, delay: 0.04 } },
          gone: {},
        }}
        className="flex h-8 origin-bottom-left items-center rounded-2xl rounded-bl-md border border-line bg-hover px-3 text-fg-3"
      >
        <TypingDots dot={5} />
      </motion.span>
    </div>
  );
}

05Props

TypingIndicator

PropTypeDefaultDescription
people*{ id: string; name: string; src?: string }[]Everyone typing now, the viewer excluded. An empty list collapses the row to zero height.
variant"inline" | "bubble""inline"A line of text for channels, or a message-shaped bubble with faces for conversations.
announcebooleantrueReads the sentence out through a polite live region. Turn off in very busy channels.

TypingDots

PropTypeDefaultDescription
dotnumber4Dot diameter in px. The three-dot wave on its own, paused off screen.

typingParts

PropTypeDefaultDescription
peopleTypingPerson[]Returns { names, rest, text }: the sentence the component uses, for your own layouts.

06Notes

Behavior

  • The sentence follows how people say it: “Ana is typing”, “Ana and Ben are typing”, “Ana, Ben and Chen are typing”, then “Ana, Ben and 2 others are typing”. First names only, set a step brighter than the verb.
  • It opens and closes its own height, so place it between a thread and a pinned composer and the thread gives up the room; nothing below it moves.
  • The bubble variant shows up to two faces stacked with a real cutout, and falls back to an initial when a photo fails.
  • The dots pause when the row is off screen or the tab is hidden.

Motion

  • The row opens to its content height in 240ms on the in-out quart while it fades and rises 4px; it closes in 180ms.
  • A new wording replaces the old in place: out 6px up and in from 6px below with a 2px blur, 220ms, so names joining and leaving read as one line changing.
  • Dots use the shared 1.2s dot-wave, 150ms apart. The bubble grows from its tail corner on the snappy spring. Reduced motion shows static dots, drops the height animation and keeps short fades.

Accessibility

  • A polite status region stays in the DOM when empty, so the first “Ana is typing” is read, and only changes to who is typing are announced.
  • The visible row and the dots are aria-hidden; the live region carries the same sentence.