CSS Dual Ring Spinner With Two Counter-Rotating Arcs

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.