Skip to main content

Harshal V. LADHE

SVG Three-Dot Loading Indicator with Staggered Delays

Build a three-dot SVG loader with staggered delays.
Read the full post: Animating SVG: A Guide to CSS Transitions and Keyframes
Published at:
Last updated:
Estimated reading time:7 min read
Cover showing three glossy dots of different sizes labeled with delays of -0.32s, -0.16s and 0s.(opens full-size image in a new tab)
Three dots shrinking left to right, labelled with delays of -0.32s, -0.16s and 0s.

Introduction

The three-dot loader is the "someone is typing" indicator: three dots that pulse one after another. In SVG it's three <circle>s and one @keyframes rule. Each dot runs the same animation, and a different animation-delay on each one turns three identical pulses into a wave.

It pairs well with the spinning SVG loader: the spinner suits "loading a page", the dots suit "waiting for a reply". Like the spinner, it takes its colour from currentColor and stays sharp at any size.

The Code

<svg class="svg-dots" viewBox="0 0 60 20" width="60" height="20" role="img" aria-label="Loading">
  <style>
    .svg-dots circle {
      fill: currentColor;
      transform-box: fill-box;
      transform-origin: center;
      animation: svg-dots-pulse 1.2s ease-in-out infinite;
    }

    .svg-dots circle:nth-of-type(1) {
      animation-delay: -0.32s;
    }

    .svg-dots circle:nth-of-type(2) {
      animation-delay: -0.16s;
    }

    @keyframes svg-dots-pulse {
      0%, 
      80%, 
      100% {
        transform: scale(0.4);
        opacity: 0.4;
      }

      40% {
        transform: scale(1);
        opacity: 1;
      }
    }

    @keyframes svg-dots-fade {
      0%, 
      80%, 
      100% {
        opacity: 0.3;
      }

      40% {
        opacity: 1;
      }
    }

    @media (prefers-reduced-motion: reduce) {
      .svg-dots circle {
        animation-name: svg-dots-fade;
        animation-duration: 2.4s;
      }

      .svg-dots circle:nth-of-type(1) {
        animation-delay: -0.64s;
      }

      .svg-dots circle:nth-of-type(2) {
        animation-delay: -0.32s;
      }
    }
  </style>
  <circle cx="10" cy="10" r="6" />
  <circle cx="30" cy="10" r="6" />
  <circle cx="50" cy="10" r="6" />
</svg>

How It Works

  1. The three circles sit on one row, 20 units apart, in a 60 × 20 viewBox. Each has a radius of 6, so there's an 8-unit gap between them at full size.
  2. svg-dots-pulse keeps each dot small and faint (scale(0.4), opacity: 0.4) for most of the cycle, and grows it to full size and full opacity at 40%. Listing 0%, 80%, 100% together gives each dot a rest between pulses.
  3. transform-box: fill-box and transform-origin: center make each circle scale around its own centre. Without them, an SVG element's transform origin is the top-left corner of the viewBox, so the dots would shrink towards 0, 0 and drift out of line.
  4. The delays are negative. A negative animation-delay starts the animation as if it had already been running for that long. The first dot is 0.32s into its cycle, the second 0.16s, and the third starts at the beginning, so the first dot peaks first and the pulse travels left to right.
  5. Negative delays also avoid a bad first frame. With positive delays, the second and third dots would sit at their un-animated state (full size, full opacity) until their delay ran out, and the loader would look broken for a moment. With negative delays, every dot is mid-cycle from the very first frame.
  6. nth-of-type() picks the circles rather than nth-child(), because the <style> element is the SVG's first child and would throw the count off.

Variant: An Opacity Pulse

For a quieter loader, drop the scaling and animate only opacity. With no transform, transform-box and transform-origin aren't needed. Slightly smaller dots with a wider gap read better when they don't change size:

<svg class="svg-dots-opacity" viewBox="0 0 60 20" width="60" height="20" role="img" aria-label="Loading">
  <style>
    .svg-dots-opacity {
      color: #6366f1;
    }

    .svg-dots-opacity circle {
      fill: currentColor;
      animation: svg-dots-opacity-pulse 1.2s ease-in-out infinite;
    }

    .svg-dots-opacity circle:nth-of-type(1) {
      animation-delay: -0.4s;
    }

    .svg-dots-opacity circle:nth-of-type(2) {
      animation-delay: -0.2s;
    }

    @keyframes svg-dots-opacity-pulse {
      0%, 
      80%, 
      100% {
        opacity: 0.2;
      }

      40% {
        opacity: 1;
      }
    }

    @media (prefers-reduced-motion: reduce) {
      .svg-dots-opacity circle {
        animation-duration: 2.4s;
      }

      .svg-dots-opacity circle:nth-of-type(1) {
        animation-delay: -0.8s;
      }

      .svg-dots-opacity circle:nth-of-type(2) {
        animation-delay: -0.4s;
      }
    }
  </style>
  <circle cx="10" cy="10" r="5" />
  <circle cx="30" cy="10" r="5" />
  <circle cx="50" cy="10" r="5" />
</svg>
  • No movement at all: only the brightness changes, so nothing on screen grows, shrinks or moves.
  • Same timing pattern: the delays step by 0.2s (-0.4s, -0.2s, 0s), a sixth of the 1.2s cycle, so the dots brighten one after another from left to right. The reduced-motion rule doubles the delays along with the duration to keep that spacing.

Example Usage

Size the loader in em so it matches the text it sits beside, and let color set the dots' colour:

.chat-typing .svg-dots {
  inline-size: 2.25em;
  block-size: 0.75em;
  color: var(--color-text-muted);
}

.button .svg-dots {
  inline-size: 1.5em;
  block-size: 0.5em;
}

Inside the button, the dots follow the label's colour without any extra rule, because color is inherited and the circles fill with currentColor.

Keep the 3 : 1 ratio of the viewBox when you set both sizes, or set inline-size together with block-size: auto. The explicit auto matters: without it, the height="20" attribute keeps the SVG 20px tall.

When the loader stands in for content, insert it into a live region that's already on the page, so screen readers are told that something is loading. A live region added at the same moment as its content usually isn't announced:

<div role="status">
  <svg class="svg-dots" viewBox="0 0 60 20" width="60" height="20" role="img" aria-label="Loading">
    <!-- circles and styles as above -->
  </svg>
</div>

Use Cases

  • Typing indicators in chat and messaging interfaces.
  • Waiting for a reply, such as an AI response or a form submission that's being processed.
  • Inline loading states inside buttons, sized in em to match the label.
  • Placeholder content in small spaces where a spinner would look too heavy.

Notes & Tips

  • Announce it properly: role="img" and aria-label="Loading" give the SVG a name, and the role="status" container announces it when it appears. You can also set aria-busy="true" on the region whose content is loading. That doesn't announce anything itself; it tells screen readers to wait until you set it back to false before reading the updated content.
  • Reduced motion: the main loader switches to svg-dots-fade, which changes only opacity, and slows it to 2.4s, doubling the delays to match. The dots stop growing and shrinking but still show that something is happening. A completely still loader can look frozen.
  • Inline SVG only: currentColor and the page's color reach the dots only when the SVG is inline in the HTML. Loaded through <img> or a CSS background-image, the SVG can't see the page's styles and the dots render black, so set an explicit fill in that case.
  • Changing the speed: if you change the duration, scale the delays with it. Here each step is 0.16s, about 13% of the 1.2s cycle. Keep the steps equal and the same share of the cycle, and the wave stays even.
  • Namespace the styles: a <style> inside an inline SVG applies to the whole page, including its @keyframes names. Keep the class and keyframe names specific, or move the rules into your normal stylesheet.
  • Browser support: transform-box works in all current browsers. Check MDN if you need to support much older ones; there, the dots would scale towards the top-left corner, so the opacity variant is the safer fallback.
  • Delays from position: in browsers that support sibling-index(), one rule can work out each dot's delay: animation-delay: calc((sibling-index() - 4) * 0.16s). The - 4 is there because sibling-index() counts every element, so the <style> is sibling 1 and the circles are 2 to 4. Every current browser supports it, but only since 2026, so keep the hand-written delays as the fallback for older versions. The payoff comes when you add or remove dots, because only the offset needs changing.
  • Keep it cheap: transform and opacity are the two properties browsers animate most efficiently, so a few of these loaders on a page cost very little.

Frequently Asked Questions

Key Takeaway

Three circles running the same @keyframes rule become a wave once each has its own animation-delay. Use negative delays so the loader is mid-cycle from the first frame, add transform-box: fill-box and transform-origin: center whenever the dots scale, and switch to an opacity-only pulse for reduced motion.

Changelog

  • — Updated browser support note for `sibling-index()`
  • — Initial publication.
This snippet is licensed under CC BY 4.0 by the author.

Share this snippet

Spotted a mistake or something outdated in this snippet? Suggest a correction.

  • CSS & SVG Loading Spinner from One Circle

    Build an SVG loading spinner from one circle.
    Build an accessible SVG loading spinner from one circle: pathLength and stroke-dasharray draw the arc, CSS spins it, with reduced-motion support.
    Published at:
  • SVG Marching Ants Border for Drop Zones and Selections

    Animate a marching dashed SVG border around any box.
    Build a marching ants border with an SVG rect and stroke-dashoffset: a seamless loop, a crisp overlay for any box size, and reduced-motion support.
    Published at:
  • SVG Circular Progress Ring with CSS Variables

    Build an SVG progress ring from one CSS variable.
    Build an accessible SVG circular progress ring: pathLength and a --progress variable set the arc, with a gradient variant and smooth value transitions.
    Published at: