Feedback

Typing Indicator

A chat presence indicator — optional avatar, a receiver-side bubble of waving dots, and a name line that collapses for groups.

Preview in your theme

Loading preview…

Dependencies
class-variance-authority
Registry dependencies
utils
"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

/**
 * Same wave shape as the chat-bubble typing dots, so an indicator dropped at the
 * end of a thread matches the bubbles above it. The media block keeps the dots
 * legible with animation off: each dot rests at its own opacity (--ti-rest).
 */
const KEYFRAMES = `@keyframes ti-wave{0%,60%,100%{transform:translateY(0);opacity:0.4}30%{transform:translateY(-35%);opacity:1}}
@media (prefers-reduced-motion:reduce){.zyeon-typing-dot{opacity:var(--ti-rest)}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/typing-indicator.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "TypingIndicator" component using
class-variance-authority (cva) for variants.

Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement> plus
  VariantProps of the cva config; remaining props spread on the root.
- Props: variant = "bubble" | "inline" (default "bubble");
  size = "sm" | "md" | "lg" (default "md"); name?: string;
  names?: string[] (wins over name); avatar?: { src: string; alt: string };
  active?: boolean (default true — whether the peer is typing right now);
  className merged last via cn().
- The component owns no timers: the consumer keeps it mounted and drives
  `<TypingIndicator active={typing} />`. Conditional mounting
  (`{typing && <TypingIndicator />}`) still renders, but the live region then
  arrives together with its text and screen readers often skip it. While
  inactive the root renders no box (display: contents); only the sr-only live
  region remains.

Behavior
- Sentence building from the people list: [] -> "Someone is typing…";
  [a] -> "a is typing…"; [a, b] -> "a and b are typing…";
  3+ -> "a and N others are typing…". Build it once per render — never
  animate or mutate the text.
- The sentence is visible above the bubble (or after the dots in inline mode)
  when at least one name is known; that visible copy is aria-hidden.
- The live region is a separate, permanently mounted sr-only span with
  role="status" + aria-live="polite", filled with the sentence only while
  active (empty otherwise) — so flipping active to true is a text change inside
  a region AT already knows, which is what gets announced. Avatar, bubble and
  dots render only while active. Because the announced text is a single static
  sentence, screen readers speak it once instead of narrating every animation
  frame; the dots themselves are aria-hidden.
- Three dots share one keyframe (translateY 0 to -35% with opacity .4 to 1,
  ~1.1s) with delays -0.48s / -0.32s / -0.16s — a left-to-right wave. The
  delays are negative so every dot is already mid-cycle on the first frame; a
  positive delay would hold the later dots at their base style until their
  turn, then snap. Match the
  keyframe shape to your chat bubble's own typing dots so an indicator dropped
  at the end of a thread looks like it belongs to the same family.
- Reduced motion stops the animation but keeps the dots readable: each one
  rests at a different opacity (0.4 / 0.7 / 1) written as an inline custom
  property and applied from a @media (prefers-reduced-motion: reduce) rule in
  the component's own stylesheet — a wave frozen mid-cycle, not three bullets.
- Avatar: render an <img> that flips to an initial-letter circle when the image
  fails. Two paths lead there and both are required: the onError handler, and a
  ref callback that on mount checks `img.complete && img.naturalWidth === 0` —
  a server-rendered (or cached) image can finish failing before hydration
  attaches onError, and that event never comes back. Store the failed src
  rather than a boolean, and derive `failed = failedSrc === src`, so swapping
  the avatar to a different person retries the new image instead of inheriting
  the previous one's verdict. The initial comes from the first name, falling
  back to the alt text. Never leave a broken image in a chat thread.
- Ship the keyframe + the reduced-motion rule in one React 19 hoisted
  <style href precedence="medium"> tag — no Tailwind config edits, duplicate
  indicators dedupe to a single style tag.

Rendering & styling
- Semantic tokens only. bubble variant mirrors an incoming chat message:
  rounded-2xl with rounded-bl-md on the tail corner, bg-muted + text-foreground,
  avatar as a bordered circle on bg-muted; the name line is
  text-muted-foreground. inline variant drops the bubble and renders dots plus
  the sentence in text-muted-foreground for a thread footer or presence row.
- Dots are bg-current, so they take the bubble's foreground token in bubble
  mode and the muted token inline — one dot implementation, two surfaces.
- Size scales avatar (size-6/8/10), bubble padding, dot diameter and the text
  size together, so all three read as one component at any density.

Customization levers
- Threshold for collapsing names: swap the 2-name branch for "a, b and N
  others" if your product shows more presence detail.
- Bubble skin: the rounded-* / bg-muted classes are the only bubble styling —
  point them at whatever your message bubble uses (including the sender-side
  bg-primary look) and the dots follow via bg-current.
- Motion strength: dot travel (-35%), cycle (1.1s) and delay step (0.16s);
  lower the travel for dense inbox lists.
- Structure: drop the avatar for compact threads, or drop the name line and
  keep it sr-only when the thread header already says who is talking.
- Sizes: extend the size record — every part class lives in one map.

Concepts

  • Presence signal — this component claims "a specific someone is composing", which is a social fact, not a technical wait; that is what separates it from a generic loader.
  • Announce once — the live region carries a single static sentence, so assistive tech says "Ada is typing…" one time instead of narrating a moving dot trio.
  • Name collapsing — group chats degrade gracefully from a name, to two names, to "and N others", so the line never wraps into a roster.
  • Receiver-side geometry — reusing the incoming bubble's radius and bg-muted makes the indicator occupy exactly the shape the real message will take, so nothing jumps when it arrives.
  • Avatar fallback — a flip to an initial circle keeps a broken CDN from leaving a torn image in the middle of a conversation; because a server-rendered image can fail before React attaches onError, the mount also probes complete && naturalWidth === 0, and the verdict is stored per src so the next person's avatar starts clean.
  • Reduced-motion resting ramp — with animation off, the frozen opacity staircase still reads as a wave; the meaning survives without any movement.

On This Page