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…

"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 };
  className merged last via cn().
- The component owns no timers and no visibility logic: the consumer mounts it
  while a peer is typing and unmounts it when the message arrives.

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; with no names it is rendered sr-only so the
  live region still says something meaningful.
- Root is role="status" + aria-live="polite". 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 / 0.16s / 0.32s — a left-to-right wave. 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