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
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/typing-indicator.jsonPrompt
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-mutedmakes 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 probescomplete && naturalWidth === 0, and the verdict is stored persrcso 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.