TH The Loading Loop
Spinner Recipes

CSS Dual Ring Spinner With Two Counter-Rotating Arcs

CSS Dual Ring Spinner With Two Counter-Rotating Arcs
tldrA CSS dual ring spinner uses one decorative element with two circular pseudo-elements. Give each a partly transparent border, keep their centers aligned, and reverse the inner arc's rotation. This recipe adds visible loading text, static reduced-motion styling and a four-second motion limit. The application, not the rotation count, determines when loading ends.

How do you make a CSS dual ring spinner?

A CSS dual ring spinner uses one decorative element with two circular pseudo-elements. Give each a partly transparent border, keep their centers aligned, and reverse the inner arc's rotation. This recipe adds visible loading text, static reduced-motion styling and a four-second motion limit. The application, not the rotation count, determines when loading ends.

Choose this shape when two concentric arcs fit your interface. They do not represent two tasks or a completion percentage. The spinner examples library compares other shapes; the single-ring recipe is a simpler visual alternative.

What code should you copy?

Paste this HTML and CSS into a document. The buttons are manual demonstration controls: they show waiting, success and failure without sending a network request. The drawing uses one span; the paragraph carries its meaning.

<button type="button" id="ring-start">Start demo</button>
<button type="button" id="ring-done">Finish demo</button>
<button type="button" id="ring-fail">Fail demo</button>
<div class="ring-row">
  <span id="ring-visual" class="dual-ring" aria-hidden="true" hidden></span>
  <p id="ring-status" role="status" aria-atomic="true"></p>
</div>
.ring-row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}
.dual-ring {
  --size: 3rem;
  --stroke: 0.1875rem;
  --outer: #1d4ed8;
  --inner: #7e22ce;
  position: relative;
  display: block;
  flex: none;
  width: var(--size);
  height: var(--size);
  padding: 0;
  border: 0;
}
.dual-ring[hidden] { display: none; }
.dual-ring::before,
.dual-ring::after {
  content: "";
  position: absolute;
  box-sizing: border-box;
  inset: 0;
  border: var(--stroke) solid transparent;
  border-top-color: var(--outer);
  border-right-color: var(--outer);
  border-radius: 50%;
  transform-origin: 50% 50%;
  transform: rotate(0turn);
}
.dual-ring::after {
  inset: 25%;
  border-top-color: var(--inner);
  border-right-color: var(--inner);
}
@keyframes dual-turn {
  from { transform: rotate(0turn); }
  to { transform: rotate(1turn); }
}
@media (prefers-reduced-motion: no-preference) {
  .dual-ring:not([hidden])::before,
  .dual-ring:not([hidden])::after {
    animation: dual-turn 1s linear 4;
  }
  .dual-ring:not([hidden])::after {
    animation-direction: reverse;
  }
}

The empty content declaration creates the generated boxes. Removing it can make a pseudo-element disappear, as MDN's before reference explains. Keep meaningful words in the HTML paragraph, not generated CSS content.

Why do the arcs rotate in opposite directions?

Both arcs use the same keyframes. The outer arc follows them forwards; the inner arc uses animation-direction: reverse, which MDN defines as playing backwards each cycle. With linear timing, they move steadily in opposite directions.

Keep the reverse declaration after the shared animation shorthand. If a later shorthand replaces it, inspect the inner arc's computed animation-direction before changing the keyframes. Animate the pseudo-elements themselves: rotating their parent would carry both arcs around together.

How do the rings stay centered when resized?

The parent is square. The outer box fills it; the inner box is inset by one-quarter of the parent's size on every side. MDN's box-sizing reference explains that border-box includes borders within the box dimensions. Equal opposing insets keep the inner box centered.

At a root font size of 16 pixels, the default 3rem is 48 pixels. This recipe's geometry is:

Measurement Calculation Result
Outer diameter 3 × 16 48 pixels
Inner inset on each side 48 × 0.25 12 pixels
Inner diameter 48 − 12 − 12 24 pixels
Border thickness 0.1875 × 16 3 pixels
Shared center from parent's top-left 48 ÷ 2 24 pixels on each axis

Each pseudo-element rotates around its own center. MDN's transform-origin reference identifies 50% as the center coordinate. Because those centers coincide, neither arc should orbit the other.

Change --size and --stroke together when making a much smaller icon. At 1.5rem, try 0.125rem strokes: with the same 16-pixel root, those are a 24-pixel outer diameter, 12-pixel inner diameter and 2-pixel borders. Keep the stroke thinner than one-quarter of the outer size so the inner opening and the gap remain visible. This limit comes from the recipe's geometry, not a universal spinner rule.

Change --outer and --inner for your theme, checking both against the background. Equal colors still preserve counter-rotation. Leave padding and borders off the parent; put surrounding decoration on a wrapper.

Why does motion stop after four seconds?

Four one-second cycles take four seconds. Afterward the base, static arcs return; the loading text remains. This is a motion limit, not a timeout or evidence that the task finished.

Motion is enabled only by prefers-reduced-motion: no-preference. Under a reduced-motion preference, the base static styling remains from the start. MDN documents these preference values.

If you replace the count with an endless loop, review WCAG's Pause, Stop, Hide criterion: nonessential moving information that starts automatically, lasts more than five seconds and appears alongside other content needs a pause, stop or hide mechanism. This bounded recipe avoids ongoing rotation; it does not establish whole-page accessibility compliance.

How should the application end the loading state?

Put this script after the markup. Finish and Fail only affect a waiting demo. Start while waiting does nothing, so repeated clicks cannot restart the motion indefinitely.

const visual = document.querySelector("#ring-visual");
const message = document.querySelector("#ring-status");
let waiting = false;

document.querySelector("#ring-start").onclick = () => {
  if (waiting) return;
  waiting = true;
  visual.hidden = false;
  message.textContent = "Loading demo content...";
};
function finishDemo(text) {
  if (!waiting) return;
  waiting = false;
  visual.hidden = true;
  message.textContent = text;
}
document.querySelector("#ring-done").onclick = () => {
  finishDemo("Demo content loaded.");
};
document.querySelector("#ring-fail").onclick = () => {
  finishDemo("Demo failed. Select Start demo to retry.");
};

For production, connect these transitions to the actual operation and remove the outcome buttons. Handle success, failure, cancellation and timeout deliberately. Never use animationend to announce success. A component removed from the page also needs its pending work and state handled by the application.

The persistent paragraph has role="status", providing polite live-region semantics. The explicit atomic attribute requests the whole message. W3C technique ARIA22 checks that the status container exists before its update. Our code changes its text at state transitions, not on animation frames. The accessible loading indicators guide covers integration with updating content regions.

What should you verify in your page?

Inspect both pseudo-elements separately. Confirm equal width and height, matching center coordinates and opposite computed rotation directions. If an arc wobbles, first check for a rectangular parent, unequal insets or an overridden transform origin. If neither appears, inspect content and border colors before adjusting animation speed.

The exact demonstration was tested in headless Microsoft Edge: counter-rotation, concentric geometry at several sizes, terminal states, retry, reduced-motion emulation and static state after the motion limit. DOM checks do not prove screen-reader announcements. Test your supported browser and assistive-technology combinations, keyboard flow, zoom and themes before shipping.

Only transform changes during rotation. MDN's performance guide explains that transforms handled in composition can avoid layout and repaint work. That is not a frame-rate promise. Profile the actual page if multiple indicators or other animations compete for resources.

Sources

FAQ

Why does the inner ring look off-center?

Inspect the parent and both generated boxes. This recipe needs a square parent and equal insets on opposing sides. At the default 48-pixel outer size, the inner box is 24 pixels wide with 12-pixel insets. Check for overriding padding, borders or transform-origin declarations before changing the animation.

Can both arcs use the same keyframes?

Yes. The example applies one rotation animation to both pseudo-elements, then gives the inner one reverse direction. Keep that declaration after the shared animation shorthand. Animating the parent instead would carry both arcs together, which would not produce the independent counter-rotation shown by this recipe.

Does stopping the animation mean loading has finished?

No. Four seconds is this example's motion limit, not its task deadline. The arcs become static while the loading message remains. Only the demonstration's outcome controls end its wait. In an application, success, failure, cancellation and timeout must come from actual task handling, never from a completed rotation.

Will a screen reader announce both rings?

The decorative span is hidden from assistive technology; the separate paragraph carries the message through a status role. Keep that container present before changing its text. Test the actual announcements with your supported screen-reader and browser combinations: checking DOM attributes alone does not establish what a person will hear.

What happens when reduced motion is enabled?

The example leaves both arcs static and retains visible loading text. Its animation declarations are inside a no-preference media query, so they do not apply when the reduced-motion setting matches. Check this preference while waiting as well as before starting, and confirm that ending the task still hides the decoration.