← Back to gallery
CSS

Typing Dots Staggered Bounce

A compact loading indicator with three expression methods: a horizontal translateY row wave, a vertical translateX column wave, and a scale-only pulse that all use staggered animation delays.

typing-dotsloaderstaggeranimation-delayscalearia-liveprefers-reduced-motion

Loader / stagger / expression methods

Typing Dots Staggered Bounce

Three compact dot loaders share the same status semantics and stagger timing, but each demonstrates a different expression method: row wave, column wave, and scale-only pulse.

Horizontal axis · translateY

Row Wave

A horizontal dot wave: same vertical keyframe, staggered per-dot delay, fixed cross-axis space.

  • row axis
  • translateY
  • delay wave

Vertical axis · translateX

Column Wave

A vertical dot wave: stacked dots, top-to-bottom delay, sideways transform inside a reserved column.

  • column axis
  • translateX
  • top-to-bottom

No travel · scale + opacity

Scale Pulse

A no-travel dot pulse: fixed positions, staggered scale and opacity, no vertical or horizontal shift.

  • scale
  • opacity
  • no travel

Typing dots inspector

Row Wave

Writing
  • row axis
  • translateY
  • delay wave

The familiar horizontal ellipsis uses one translateY keyframe and left-to-right animation delays. The wrapper reserves cross-axis height so the bounce never moves nearby UI.

Helped you ship something? 🐟 Send my cat a churu

.typing-status {
  display: inline-flex;
  align-items: center;
  gap: .55rem;
  min-height: 2rem;
  line-height: 1;
  color: #dff8ff;
}

.typing-dots {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: .28rem;
  line-height: 1;
  vertical-align: middle;
}

.typing-dots--row-wave {
  --typing-expression: row-wave;
}

.typing-dots--row-wave {
  height: calc(7px + 6px + 2px);
}

.typing-dots span {
  width: 7px;
  height: 7px;
  border-radius: 999px;
  background: #67e8f9;
  transform-origin: center;
  animation: typing-dot-row-wave 1.20s ease-in-out infinite;
}

.typing-dots span:nth-child(2) {
  animation-delay: 0.16s;
}

.typing-dots span:nth-child(3) {
  animation-delay: 0.32s;
}

@keyframes typing-dot-row-wave {
  0%, 80%, 100% {
    transform: translateY(0) scale(.94);
    opacity: .45;
  }
  40% {
    transform: translateY(-6px) scale(1);
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .typing-dots span {
    animation: none;
    opacity: .72;
    transform: none;
  }
}

How to make this

Typing dots use one shared dot keyframe and stagger only animation-delay, so a compact status can read as motion without changing the row height.

html
1<div class="typing-status" role="status" aria-live="polite">  <span class="typing-status__label">Assistant is typing</span>  <span class="typing-dots" aria-hidden="true">    <span></span>    <span></span>    <span></span>  </span></div> <style>.typing-status {  display: inline-flex;  align-items: center;  gap: .55rem;  min-height: 2rem;  line-height: 1;  color: #dff8ff;} .typing-dots {  display: inline-flex;  align-items: center;  justify-content: center;  gap: .28rem;25  height: 1rem;} .typing-dots span {  width: .42rem;  height: .42rem;  border-radius: 999px;  background: #67e8f9;  animation: typing-dot-bounce 1.2s ease-in-out infinite;} .typing-dots span:nth-child(2) {37  animation-delay: .16s;} .typing-dots span:nth-child(3) {  animation-delay: .32s;} @keyframes typing-dot-bounce {  0%, 80%, 100% {46    transform: translateY(0);    opacity: .45;  }  40% {    transform: translateY(-.34rem);    opacity: 1;  }} 55@media (prefers-reduced-motion: reduce) {  .typing-dots span {    animation: none;    opacity: .72;    transform: none;  }}</style>

Annotated snippet

  1. Line 1The status text is semantic and polite; the dots are decorative. Screen readers should hear one status, not three animated punctuation marks.
    PitfallShould the animated dots be announced to screen readers?

    No. Put role="status" and aria-live="polite" on the containing message, then mark the dot group aria-hidden. The accessible text should announce the state once, not every animated dot.

  2. Line 25Reserve cross-axis space for the dot row. If the wrapper height depends on the current animated frame, the loader can change line-height and push surrounding UI.

    BEFORE animates layout-affecting margin on the dot, so the status row can measure differently as the dots bounce. AFTER keeps a fixed-height row and moves only transform, so the same height: 82px stage stays stable.

    PitfallHow do I stop typing dots from changing row height?

    Reserve a fixed cross-axis height on the dot wrapper and keep the status row line-height stable. The animated dots can travel inside that reserved space without changing the surrounding layout.

  3. Line 37Every dot uses the same shared dot keyframe. The phase offset comes from animation-delay, which keeps the CSS easy to tune and avoids maintaining three nearly identical keyframes.

    Both examples use the same keyframe. BEFORE starts every dot together, so the loader blinks in sync. AFTER staggers animation-delay and reads as a staggered wave.

    PitfallWhy stagger animation-delay instead of writing three keyframes?

    A shared keyframe keeps the bounce shape consistent. Staggering animation-delay changes phase only, which is easier to tune and makes the three-dot wave reusable across chat, inline, and agent-status surfaces.

  4. Line 46Use transform for the bounce. Transform changes the painted position without asking the surrounding text or row to re-measure.
    PitfallWhy use transform instead of top, margin, or height?

    Transform moves the painted dot without changing layout. top, margin, or height can alter line metrics, push neighboring text, or cause below content to shift while the loader loops.

  5. Line 55Reduced motion should stop the indefinite loop and leave a visible static loading marker. Replacing one loop with another pulse misses the user preference.
    PitfallHow should reduced motion handle typing dots?

    Disable the bounce and show the three dots at a steady opacity. Do not replace it with a fade, wave, or alternate loop; the reduced-motion state should be static.

Other pitfalls

When should an indefinite typing dots loader stop?
Stop it as soon as the real state is known, and provide a timeout or fallback message for long waits. A typing indicator that loops forever after a request failed becomes misleading status.
Can many typing dots loaders run at once?
Avoid it in long lists. A few small transform animations are cheap, but dozens of indefinite loops create visual noise and can still cost battery. Prefer one contextual status for bulk loading.

Notes

Overview

Typing dots are a compact indefinite loader: three small circles use staggered animation-delay so they read as a wave instead of blinking in sync. The pattern is not inherently chat-specific, and the showcase should vary the expression method: a horizontal translateY() row wave, a vertical translateX() column wave, or a no-travel scale pulse.

When to use it

Use this when the system is actively working but cannot report determinate progress. It is strongest for short waits in tight UI: composing a reply, checking a field, or waiting on an assistant step. Avoid it for long-running work where users need progress, retry affordances, or a cancel path.

How it works

Build a semantic status wrapper with role="status" and aria-live="polite", then mark the dot group aria-hidden. The dots use identical size and shape inside each expression method; :nth-child() offsets the second and third dot with staggered animation-delay. Reserve the wrapper footprint for the chosen method so row bounce, column nudge, or scale pulse all happen inside stable line metrics.

Production gotchas

Do not animate top, margin, orheight for the bounce. Those properties can re-measure inline content, push neighboring controls, or make UI below the row jump on every loop. Keep the moving property to transform, and keep the cycle calm enough that the loader reads as waiting rather than urgency.

Accessibility

The dots are visual decoration; the status copy carries the accessible meaning. Under prefers-reduced-motion: reduce, stop the bounce and show the dots at a steady opacity. Do not replace the bounce with another fade or pulse, and do not leave a typing indicator running after the request has failed or completed.

References

Implementation depth

Typing dots are a loader timing pattern, not a chat-only component. The reusable mechanism is staggered animation-delay over fixed-position dots; the expression can be a horizontal row wave, a vertical column wave, or a no-travel scale pulse.

The production risk is layout movement and misleading status. Reserve the wrapper footprint for the chosen expression, animate transform rather than margin or height, expose a single polite status message, and stop the indefinite loop when the real state resolves or fails.