Skip to content
AI & Chat primitive

Typing Indicator

Three dots that stop moving for anyone who asked their OS for less motion, and a polite live region that says who is typing — so the information was never carried by the animation in the first place.

98 linesNo dependenciesAdded 14 Sept 2026
  • typing
  • loading
  • streaming
  • ai
  • skeleton
  • motion

What's included

  • components/ui/typing-indicator.tsx
  • No runtime dependencies

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add typing-indicator

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Live

Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.

Assistant is typing
Drafting an answer
ConnectingConnecting to the run

Source

One file. Create components/ui/typing-indicator.tsx and paste.

components/ui/typing-indicator.tsx
/**
 * <TypingIndicator> — the gap between sending a prompt and the first token.
 *
 * A model can take fifteen seconds to say anything, and fifteen seconds of
 * an empty thread is indistinguishable from a dropped request. This is what
 * fills it — and it is almost always built wrong in two specific ways.
 *
 * **The animation is `motion-safe:` and nothing else is.** An infinite
 * bounce with no `prefers-reduced-motion` escape is a 2.2.2 failure and a
 * genuine vestibular trigger; a reader who has asked their OS for less
 * motion gets three static dots here. What they do *not* lose is the
 * information, because the information was never in the movement — it is in
 * the label, which is always present.
 *
 * **The dots are `aria-hidden` and the label is the live region.** The
 * instinct is to drop `aria-live` on the whole thing, which then announces
 * a decorative animation, or on nothing, which announces no wait at all.
 * `role="status"` on the wrapper with the words in `sr-only` says "Assistant
 * is typing" once, politely, and never interrupts the user mid-sentence.
 *
 * A note on what this is *not*: it does not count. `<AgentWorkingIndicator>`
 * in the blocks tier does, and for waits over a few seconds a ticking
 * elapsed time is the honest answer — a spinner keeps spinning after the
 * socket dies. This is the short-wait version.
 */

import * as React from 'react'

export interface TypingIndicatorProps {
  /**
   * `dots` for a pending turn, `shimmer` for a message skeleton, `pulse`
   * for a single quiet glyph beside existing text.
   */
  variant?: 'dots' | 'shimmer' | 'pulse'
  /** Announced politely. Say who, not just that something is happening. */
  label?: string
  /** Draws the dots inside a bubble matching `<MessageBubble role="assistant">`. */
  bubble?: boolean
  className?: string
}

const DOT_DELAYS = ['0ms', '150ms', '300ms']

export function TypingIndicator({
  variant = 'dots',
  label = 'Assistant is typing',
  bubble = false,
  className = '',
}: TypingIndicatorProps) {
  const body =
    variant === 'shimmer' ? (
      <span aria-hidden="true" className="flex w-full flex-col gap-2">
        {/*
         * `bg-muted-foreground/20`, not `bg-muted`. A skeleton bar sits
         * inside an assistant bubble as often as on a card, and that bubble
         * is itself `bg-muted` — a bar of the same token on the same token
         * is a correctly-rendered invisible component, which typechecks,
         * lints, and passes every test that does not open a browser.
         */}
        <span className="h-2.5 w-4/5 rounded-full bg-muted-foreground/20 motion-safe:animate-pulse border border-transparent" />
        <span className="h-2.5 w-3/5 rounded-full bg-muted-foreground/20 motion-safe:animate-pulse border border-transparent" />
      </span>
    ) : variant === 'pulse' ? (
      <span
        aria-hidden="true"
        className="size-2 rounded-full bg-muted-foreground motion-safe:animate-pulse border border-transparent"
      />
    ) : (
      <span aria-hidden="true" className="flex items-center gap-1">
        {DOT_DELAYS.map((delay) => (
          <span
            key={delay}
            style={{ animationDelay: delay }}
            className="size-1.5 rounded-full bg-muted-foreground motion-safe:animate-bounce border border-transparent"
          />
        ))}
      </span>
    )

  return (
    <span
      role="status"
      aria-live="polite"
      className={[
        'inline-flex items-center',
        variant === 'shimmer' ? 'w-full' : '',
        bubble ? 'rounded-2xl rounded-ss-sm bg-muted px-3.5 py-3' : '',
        className,
      ]
        .filter(Boolean)
        .join(' ')}
    >
      {body}
      <span className="sr-only">{label}</span>
    </span>
  )
}

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
variant`dots` for a pending turn, `shimmer` for a message skeleton, `pulse` for a single quiet glyph beside existing text.'dots' | 'shimmer' | 'pulse''dots'
labelAnnounced politely. Say who, not just that something is happening.string'Assistant is typing'
bubbleDraws the dots inside a bubble matching `<MessageBubble role="assistant">`.booleanfalse
classNamestring''

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

More ai & chat

View all
Open the full page for this primitive

Message Bubble

One turn in a thread, with the part every chat UI leaves out: the speaker is in the text rather than implied by which side the bubble sits on, so a screen reader hears a conversation instead of an unattributed monologue.

AI & Chat178 linesNo deps
Open the full page for this primitive

Prompt Input

The composer, reduced to the four things that are actually hard: Enter that does not fire mid-IME-composition, one submit path for button and keyboard alike, stop as a separate button from send, and a row cap measured from the real line height.

AI & Chat177 lines1 dep
Open the full page for this primitive

Tool Call

A single tool invocation that opens into its arguments and result — status carried by a glyph and a word rather than a colour, arguments as a definition list, and a duration formatted arithmetically so it cannot drift at hydration.

AI & Chat202 lines1 dep