TH The Loading Loop
Spinner Recipes

CSS Three-Dot Loader With Smooth Staggered Motion

CSS Three-Dot Loader With Smooth Staggered Motion
tldrBuild a CSS three-dot loader with three equal circular spans sharing one keyframe animation. Animate `transform: translateY()` and `opacity`, then apply increasing `animation-delay` values to the second and third dots for a traveling wave. Mark the dots `aria-hidden="true"`, place descriptive text in a `role="status"` container, and stop the animation when `prefers-reduced-motion: reduce` matches.

Three dots need one animation and three delays

A CSS three-dot loader uses three equal circles running the same keyframe with staggered animation-delay values. Animate transform: translateY() and opacity so the dots rise in sequence without changing layout. Keep the dots decorative, add a real text status, and replace the loop with a static state when the user prefers reduced motion.

This complete example creates a compact wave:

<div class="dot-loader" role="status" aria-atomic="true">
  <span class="dot-loader__label">Sending message</span>
  <span class="dot-loader__dots" aria-hidden="true">
    <span class="dot-loader__dot"></span>
    <span class="dot-loader__dot"></span>
    <span class="dot-loader__dot"></span>
  </span>
</div>
.dot-loader {
  --dot-size: 0.5rem;
  --dot-color: #4338ca;
  --dot-gap: 0.3rem;

  display: inline-flex;
  align-items: baseline;
  gap: 0.5rem;
  color: #111827;
  font: 600 1rem/1.4 system-ui, sans-serif;
}

.dot-loader__dots {
  display: inline-flex;
  align-items: center;
  gap: var(--dot-gap);
  min-height: calc(var(--dot-size) * 2.4);
}

.dot-loader__dot {
  width: var(--dot-size);
  height: var(--dot-size);
  flex: 0 0 auto;
  border-radius: 50%;
  background: var(--dot-color);
  opacity: 0.35;
  animation: dot-wave 0.9s ease-in-out infinite;
}

.dot-loader__dot:nth-child(2) {
  animation-delay: 0.15s;
}

.dot-loader__dot:nth-child(3) {
  animation-delay: 0.3s;
}

@keyframes dot-wave {
  0%,
  60%,
  100% {
    transform: translateY(0);
    opacity: 0.35;
  }

  30% {
    transform: translateY(calc(var(--dot-size) * -0.7));
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .dot-loader .dot-loader__dots .dot-loader__dot {
    animation: none;
    opacity: 0.75;
  }
}

The three nested dot spans share one rule. Only their delays differ. The wrapper reserves 2.4 times the dot height. With a centered 0.5rem dot, the 1.2rem box leaves 0.35rem above it, matching the 0.35rem upward travel. Transforms do not change the element's layout box, so that reserved room prevents clipping or overlap.

Why the stagger works

Each dot follows a 0.9-second cycle. The first begins immediately, the second 0.15 seconds later, and the third 0.3 seconds later. Since the animation repeats forever, those offsets create a traveling wave.

At 0%, 60%, and 100%, a dot rests at its original position with lower opacity. At 30%, it moves upward by 70% of its own diameter and becomes fully opaque. Repeating the resting state across the latter part of the timeline creates breathing room before the next cycle.

The arithmetic of the start delays is straightforward:

The delay does not make the whole component 0.3 seconds slower. It offsets each dot within a continuous loop.

Customize without breaking the rhythm

Size, gap, and color live in custom properties:

.dot-loader--quiet {
  --dot-size: 0.375rem;
  --dot-gap: 0.25rem;
  --dot-color: #475569;
}

To slow the pattern, change all dots through the shared rule:

.dot-loader--slow .dot-loader__dot {
  animation-duration: 1.2s;
}

.dot-loader--slow .dot-loader__dot:nth-child(2) {
  animation-delay: 0.2s;
}

.dot-loader--slow .dot-loader__dot:nth-child(3) {
  animation-delay: 0.4s;
}

Here the delay step is one-sixth of the duration: 1.2s ÷ 6 = 0.2s. Keeping delays proportional preserves a similar cadence. You can choose another rhythm, but adjust it intentionally rather than tuning decimals by vibes.

For an in-place fade instead of a bounce, remove the transform change and animate only opacity:

@keyframes dot-fade {
  0%,
  60%,
  100% {
    opacity: 0.25;
  }

  30% {
    opacity: 1;
  }
}

.dot-loader--fade .dot-loader__dot {
  animation-name: dot-fade;
}

That variant creates no spatial motion; only opacity changes. The reduced-motion selector in the complete example has three class selectors, specificity (0,3,0), while the fade variant has two, (0,2,0). The reduced-motion rule therefore wins on specificity. Source order breaks ties only when competing declarations have equal cascade weight and specificity.

Use spans, pseudo-elements, or a background?

Three spans are deliberately boring and maintainable. They make per-dot delays obvious and allow the dots to inherit component variables.

A wrapper, one child, and two pseudo-elements can still animate three independent dots. The pseudo-elements become the outer dots; the child becomes the middle dot:

<span class="dot-loader__pseudo" aria-hidden="true"><span></span></span>
.dot-loader__pseudo {
  --dot-size: 0.5rem;

  display: inline-flex;
  gap: 0.3rem;
}

.dot-loader__pseudo::before,
.dot-loader__pseudo::after,
.dot-loader__pseudo > span {
  width: var(--dot-size);
  height: var(--dot-size);
  border-radius: 50%;
  background: currentColor;
  animation: dot-wave 0.9s ease-in-out infinite;
}

.dot-loader__pseudo::before,
.dot-loader__pseudo::after {
  content: "";
}

.dot-loader__pseudo > span { animation-delay: 0.15s; }
.dot-loader__pseudo::after { animation-delay: 0.3s; }

This saves two child elements, but the relationship between visual order and selector order is less obvious than three spans. A box-shadow trick can draw all three circles from one element, yet a single shadow list cannot give each circle an independent animation delay. Use it for a static reduced-motion mark, not for this staggered wave.

Choose the smallest code that your team can understand. DOM minimalism is not a competitive sport. Three decorative spans do not burden a normal interface, while a clever one-element construction can burden the next person who must change its timing.

Keep status meaning outside the animation

The dot group has aria-hidden="true" because its shapes do not identify what is happening. The parent role="status" contains “Sending message,” which assistive technology can announce as a polite update.

Use a message specific to the action:

Avoid a bare “Please wait” when the interface can say what it is doing. If the task fails, replace the loading message with the error and a recovery path. A timeout still needs an error state and a recovery action; the dots cannot negotiate with the server.

For a dynamically inserted message, keep the live-region container present before updating its contents. The broader accessible loading indicators guide explains live-region timing, aria-busy, and progress semantics.

Hide the label visually only when context is strong

An inline “Sending message” label is useful to everyone. If a button already reads “Send” and changes to a dot indicator within the same stable control, you may visually hide a fuller status message:

<div class="dot-loader" role="status" aria-atomic="true">
  <span class="visually-hidden">Sending message…</span>
  <span class="dot-loader__dots" aria-hidden="true">
    <span class="dot-loader__dot"></span>
    <span class="dot-loader__dot"></span>
    <span class="dot-loader__dot"></span>
  </span>
</div>

Use your project's tested visually-hidden utility. Do not use display: none or the hidden attribute on the status text because that removes it from visual display and the accessibility tree.

Keep the button's width stable between idle and loading states. Replacing a wide label with three narrow dots can make adjacent controls jump. Reserve width with component sizing or keep the original label visibly present.

Reduced motion should preserve the state

MDN documents prefers-reduced-motion as a media feature for detecting a user request to remove, reduce, or replace non-essential motion. The snippet stops the wave and leaves three moderately opaque dots beside the label.

Another valid reduced-motion design is to hide the decorative dots while retaining visible text:

@media (prefers-reduced-motion: reduce) {
  .dot-loader__dots {
    display: none;
  }
}

Use one approach, not both. A static dot group preserves the component's visual footprint; hiding it reduces decoration further. Neither should remove the status message.

Performance: keep the animation local

The wave animates transform and opacity, which avoid geometry changes and can often be composed efficiently. The moving dots still consume resources, especially when repeated across many list items or left running in a background tab.

Render an indicator only for work that is actually pending. Remove it when complete, cancel its task when the associated view disappears, and avoid placing separate infinite loaders on every stand-in if one container-level status is enough.

If a grid of content needs its final shape reserved, use a skeleton loader with restrained animation rather than dozens of independent dot groups. If a compact circular signal better matches the interface, use the border-based CSS spinner.

Common three-dot problems

All dots move together

Confirm the :nth-child() selectors target the dot spans and that later CSS has not overwritten animation-delay. The spans must be siblings inside the dots wrapper.

The dots are clipped

An ancestor may use overflow: hidden, or the wrapper may not reserve vertical room. Keep the min-height in the example and inspect nearby line-height and clipping rules.

The baseline looks awkward

Try align-items: center on .dot-loader if the label's font and dot size do not sit comfortably with baseline. Visual alignment is font-dependent.

A global span rule changes the circles

Component class selectors should win over broad element rules, but inspect inherited display, border, and transform declarations. Encapsulated class names reduce collisions.

The message is announced repeatedly

Do not rewrite identical live-region text on every animation cycle. CSS motion should never drive status announcements. Update text only when application state meaningfully changes.

A three-dot loader earns its place by being quiet, specific, and temporary. The stagger gives it personality; the text gives it meaning; the application gives it an ending.

A quick browser test before shipping

Test the component at normal and 200% page zoom, beside both short and long translated labels, and inside the narrowest container where it can appear. Toggle the operating system's reduced-motion preference while the page is open and confirm the loop stops. In developer tools, inspect all three dots: their computed delays should be 0s, 0.15s, and 0.3s in the base version.

Finally, slow the associated network request and force it to succeed, fail, and be cancelled. The status should announce once when the state changes, the dots should disappear in every terminal state, and the surrounding layout should not jump. A loader tested only in eternal loading has passed the least interesting third of its exam.

FAQ

How do I stagger three dots in CSS?

Apply the same animation to all three sibling dots, then set increasing delays with `:nth-child()`. For a 0.15-second step, the first uses zero delay, the second `0.15s`, and the third `0.3s`. Because the animation repeats, these offsets create a continuous sequence rather than delaying the whole loader.

Which properties should a dot loader animate?

Use `transform` for movement and `opacity` for emphasis. They do not alter document geometry and can often be handled efficiently during compositing. Avoid animating margin, top, width, or other layout-affecting properties for the bounce. Performance still depends on the number of animations, page complexity, browser, and device.

Do animated loading dots need text?

Yes, when they communicate application state. The shapes do not explain what is loading, saving, or sending. Hide the dots from assistive technology and provide a specific status message in a live region. Keep the text visible when useful; if visually hidden, use a tested utility rather than `display: none`.

How should a dot loader handle reduced motion?

Inside `@media (prefers-reduced-motion: reduce)`, set the dots' animation to `none` and leave them static, or hide only the decorative dot group. Preserve the visible or assistive status text in either case. Avoid swapping the bounce for another continuous scale, pan, or strong pulse that may remain uncomfortable.

Why do all my loading dots animate at once?

The delay selectors may not match the actual sibling dots, or a later shorthand `animation` rule may reset `animation-delay`. Confirm the three dot elements share one parent, inspect the computed styles for the second and third children, and place delay rules after the shared animation shorthand if specificity is equal.

Can I make a three-dot loader with one HTML element?

Not with three independently timed dots and no generated content. A wrapper can create the outer dots with `::before` and `::after` while one child supplies the middle dot. Box shadows can draw three circles from one element, but independently staggering those painted shadows is awkward. Prefer the version your team can test and maintain.