CSS Loading Spinner
Four spinner variants (ring, dots, bars, and orbit) built entirely with CSS animations.
A CSS loading spinner is a looping @keyframes animation applied to a shape the browser can already draw, which means no image, no SVG and no JavaScript timer. The four on this page cover the shapes that show up most often. A ring built from a single lit border side, a row of dots scaling in sequence, a set of bars rising and falling, and two rings orbiting in opposite directions.
All four share one idea: define the motion once in a keyframe set, then vary duration and animation-delay per element to get a wave out of identical children. Two more versions follow the code below, one that ticks through eight discrete positions using steps() and one drawn with a conic-gradient so the arc can be any length rather than a multiple of a quarter turn.
Four spinners, one technique
HTML
<div class="spinner-grid">
<div class="spinner-item">
<div class="spinner-ring"></div>
<span class="spinner-label">Ring</span>
</div>
<div class="spinner-item">
<div class="spinner-dots">
<span></span><span></span><span></span>
</div>
<span class="spinner-label">Dots</span>
</div>
<div class="spinner-item">
<div class="spinner-bars">
<span></span><span></span><span></span><span></span>
</div>
<span class="spinner-label">Bars</span>
</div>
<div class="spinner-item">
<div class="spinner-orbit"></div>
<span class="spinner-label">Orbit</span>
</div>
</div>
CSS
.spinner-grid {
display: flex;
gap: 2.5rem;
align-items: center;
justify-content: center;
flex-wrap: wrap;
padding: .5rem;
}
.spinner-item {
display: flex;
flex-direction: column;
align-items: center;
gap: .75rem;
}
.spinner-label {
font-size: .75rem;
font-weight: 500;
color: #88888f;
}
/* Ring: one lit border side, spun forever */
.spinner-ring {
width: 40px;
height: 40px;
border: 3px solid #1c1c1e;
border-top-color: #b8ff57;
border-radius: 50%;
animation: spin-ring .8s linear infinite;
}
/* Dots: same animation, staggered delays */
.spinner-dots {
display: flex;
gap: 6px;
align-items: center;
}
.spinner-dots span {
width: 10px;
height: 10px;
background: #b8ff57;
border-radius: 50%;
animation: spin-dots 1.2s ease-in-out infinite;
}
.spinner-dots span:nth-child(2) { animation-delay: .2s; }
.spinner-dots span:nth-child(3) { animation-delay: .4s; }
/* Bars: animate height off a flex-end baseline */
.spinner-bars {
display: flex;
gap: 4px;
align-items: flex-end;
height: 36px;
}
.spinner-bars span {
width: 6px;
background: #b8ff57;
border-radius: 3px;
animation: spin-bars 1s ease-in-out infinite;
}
.spinner-bars span:nth-child(2) { animation-delay: .15s; }
.spinner-bars span:nth-child(3) { animation-delay: .3s; }
.spinner-bars span:nth-child(4) { animation-delay: .45s; }
/* Orbit: two rings spinning at different speeds, one reversed */
.spinner-orbit {
position: relative;
width: 40px;
height: 40px;
animation: spin-ring .8s linear infinite;
}
.spinner-orbit::before {
content: '';
position: absolute;
inset: 0;
border: 3px solid transparent;
border-top-color: #38bdf8;
border-right-color: #c084fc;
border-radius: 50%;
}
.spinner-orbit::after {
content: '';
position: absolute;
inset: 6px;
border: 3px solid transparent;
border-bottom-color: #f472b6;
border-radius: 50%;
animation: spin-ring .5s linear infinite reverse;
}
@keyframes spin-ring {
to { transform: rotate(360deg); }
}
@keyframes spin-dots {
0%, 80%, 100% { transform: scale(.6); opacity: .4; }
40% { transform: scale(1); opacity: 1; }
}
@keyframes spin-bars {
0%, 100% { height: 10px; opacity: .4; }
50% { height: 36px; opacity: 1; }
}
Other ways to build it
A stepped tick instead of a sweep
Swapping linear for steps(8) reuses the same spin-ring keyframes and turns the sweep into eight discrete jumps of 45 degrees, the motion old platform spinners used. The default is jump-end, so the first frame shown is 0 degrees and the last position before the loop restarts is 315. Ask for steps(8, jump-start) instead and the ring is already at 45 degrees on the first frame, which shifts the whole cycle by one step. Both spinners below run off one keyframe set.
HTML
<div class="spinner-grid">
<div class="spinner-item">
<div class="spinner-ring"></div>
<span class="spinner-label">linear</span>
</div>
<div class="spinner-item">
<div class="spinner-ring ls-stepped"></div>
<span class="spinner-label">steps(8)</span>
</div>
</div>
CSS
/* uses @keyframes spin-ring from the main example above; the rules here are
what changes, not the whole file */
/* same keyframes as the ring above, different timing function */
.ls-stepped {
animation: spin-ring 1s steps(8) infinite;
}
Any arc length, using a conic gradient
A border can only light a whole side at a time, so a border ring gives arcs in multiples of a quarter turn. A conic-gradient has no such limit: the two color stops below put the accent across the first 30 percent of the circle and the track across the rest, and any other split works the same way. The gradient paints a filled disc, so a radial mask punches out everything inside the outer 5px and leaves a ring. The -webkit- line is there for WebKit builds that predate the unprefixed shorthand.
HTML
<div class="ls-conic"></div>
<div class="ls-conic ls-conic-thick"></div>
CSS
/* uses @keyframes spin-ring from the main example above; the rules here are
what changes, not the whole file */
.ls-conic {
width: 40px;
height: 40px;
border-radius: 50%;
/* 30% of the circle lit, the rest is the track */
background: conic-gradient(#b8ff57 0 30%, #1c1c1e 30%);
/* the mask is what turns the disc into a ring */
-webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 5px), #000 calc(100% - 5px));
mask: radial-gradient(farthest-side, #0000 calc(100% - 5px), #000 calc(100% - 5px));
animation: spin-ring 1.1s linear infinite;
}
.ls-conic-thick {
-webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 11px), #000 calc(100% - 11px));
mask: radial-gradient(farthest-side, #0000 calc(100% - 11px), #000 calc(100% - 11px));
}
@media (prefers-reduced-motion: reduce) {
/* slowed rather than stopped: a frozen spinner tells the reader nothing */
.ls-conic { animation-duration: 4s; }
}
How it works
Ring spinner: a circle border with one side transparent, rotating via @keyframes. Dots spinner: three circles that scale up and down with staggered animation-delay. Bars spinner: vertical bars that animate height with staggered delays. Orbit spinner: two pseudo-elements rotating at different speeds in opposite directions using animation-direction: reverse. All use only @keyframes, border, and transforms.
The ring is the cheapest of the four. A square with border-radius: 50% is a circle, a 3px border in a dim color is its track, and overriding border-top-color lights one quarter of that track. Nothing about the arc itself moves. The element rotates, and the lit quarter goes round with it. Only transform is animated, so the browser can hand the whole thing to the compositor and never run layout or paint again for the life of the page.
The dots and the bars use one keyframe set across identical children and separate them with animation-delay. A positive delay holds the element on its starting value until the delay elapses, so for the first fifth of a second after load the third dot has not begun and the wave is still filling in. A negative delay does the opposite and drops the element into the middle of a cycle straight away, which is what you want when the pattern should look established from the very first frame. Both are useful; they are not interchangeable.
A timing function is applied to each keyframe segment, not to the animation as a whole. spin-dots has stops at 0, 40, 80 and 100 percent, so ease-in-out runs three separate times: once growing, once shrinking, and once across a segment where both ends hold the same value and nothing visible happens. That last gap is the pause between pulses. The consequence to watch for is that you cannot draw a single easing curve across a multi-stop keyframe. Add an intermediate stop to an eased animation and the curve resets there, which shows up as the motion slowing to a stop in the middle and then picking up again.
The bars animate height, and that is the one place on this page where the browser has to do layout work on every frame. transform: scaleY() with transform-origin: bottom would produce a similar shape on the compositor instead. The trade is that scaling stretches the 3px rounded ends into ovals, while animating height keeps the corner radius honest at every size. Four small bars are not going to trouble anything, so the version here keeps the crisper corners. A wall of fifty of them would be worth converting.
The orbit spinner is two pseudo-elements over one box. ::before lights two adjacent border sides and rides along with the parent's rotation. ::after is inset by 6px and carries its own faster animation with animation-direction: reverse, so the inner ring turns the other way. Because both pseudo-elements are position: absolute, neither contributes anything to the parent's size, which is why .spinner-orbit declares an explicit 40 by 40 box. Take those two lines away and the element collapses to zero inside the flex row and the spinner disappears completely, with nothing logged anywhere to say why.
CSS properties used
@keyframes- Defines the motion once so several elements can share it. All four spinners here draw from three keyframe sets between them.
animation- The shorthand that binds a keyframe set to an element with a duration, a timing function and an iteration count.
infiniteis what makes a spinner a spinner. animation-delay- Offsets an element within the shared cycle. Positive values hold at the start value first, negative values begin part way through immediately.
animation-directionreverseplays the keyframes backwards, which is how the inner orbit ring turns against the outer one without a second keyframe set.animation-timing-function- Shapes each segment between keyframe stops independently.
linearfor continuous rotation,steps(n)for a discrete tick. border-radius50%on a square makes the circle. On the bars it rounds the ends, which is the reason those animate height rather than scale.transformrotate()for the rings andscale()for the dots. Both run on the compositor without touching layout.mask- Punches the hole out of the conic gradient variant, turning a filled disc into a ring of any thickness.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
CSS animation | 4 | 5 | 5.1 | 12 |
2D transforms | 4 | 3.5 | 3.1 | 12 |
border-radius | 4 | 3 | 3.1 | 12 |
conic gradients | 69 | 83 | 12.1 | 79 |
CSS masks | 120 | 53 | 15.4 | 120 |
The first three rows are old enough that no fallback is worth writing, and none of them need a vendor prefix any more. The conic gradient variant is the one with a real floor, and the mask row is the binding constraint on it: Can I Use only counts Chromium as fully supporting mask from version 120, because the unprefixed shorthand landed late even though -webkit-mask worked for years before that. Ship both declarations if the conic ring matters to you, and note that without the mask the element renders as a filled disc rather than a ring, which is a visible failure rather than a silent one.
Accessibility notes
A spinner says nothing to a screen reader. The element is empty, its content comes from borders and backgrounds, and nothing about it is announced. Give it an accessible name and a live region: role="status" on the wrapper with visually hidden text reading Loading, or aria-label on the wrapper with aria-hidden on the spinning element itself. Without one of those, the page is silent for the entire wait.
Reduced motion needs more care here than on a decorative animation. Removing the animation under prefers-reduced-motion: reduce leaves a frozen graphic that no longer communicates anything, which is worse than the original problem. Slow the rotation down instead, or replace it with an opacity pulse. Rotation is a common vestibular trigger; a fade is not, and it still reads as activity.
Length matters as much as motion. A spinner is honest for a second or two of waiting. Past that it becomes an unbounded wait with no feedback, and readers start guessing whether the page is broken. Anything longer is better served by a skeleton loader, which shows where the content will land, or a progress bar if the work has a known size.
What you can build with it
- Button busy states. A small ring dropped inside a button after submit, so the control shows the click was received. Progress buttons build the whole pattern out.
- Route transitions. A centered spinner while a client side router fetches the next view, sized large enough to read as the page state rather than a component state.
- Infinite scroll. The dots spinner at the foot of a list while the next page loads. Horizontal dots take less vertical space than a ring, which keeps the list from jumping.
- Inline refresh. A small ring next to a value that is being revalidated, leaving the stale number readable underneath instead of blanking the cell.
- Media placeholders. A spinner centered in an aspect ratio box while an image or an embed downloads, so the layout is already reserved when the content arrives.
Mistakes worth avoiding
- Building a spinner entirely from
::beforeand::afterwithout sizing the host. Absolutely positioned children contribute nothing to their parent, so the element collapses to zero inside a flex row and renders as blank space with nothing in the console to explain it. - Animating
width,heightortopwhentransformwould do. Those run layout on every frame. It is invisible on one small spinner and very visible once a list has thirty of them. - Turning the animation off completely for
prefers-reduced-motion. The spinner freezes into a static ring that means nothing at all. Slow it or swap it for a fade instead of stopping it. - Using positive
animation-delaywhen the wave should already be running. Every element sits on its start value for the length of its delay, so a staggered set looks broken for the first cycle. Negative delays skip that. - Showing the spinner immediately. Anything that resolves in under about 200ms produces a flash of spinner that reads as a glitch. Delay the appearance so quick responses never show one.
Frequently asked questions
How do I change the speed of a CSS spinner?
animation-duration in the shorthand. The keyframes describe the shape of the motion and know nothing about time, so spin-ring .8s and spin-ring 3s run exactly the same rotation at different speeds. Nothing else has to be edited.Why is my CSS spinner invisible?
Can one spinner class have different colors?
border-top-color: var(--spinner-color, #b8ff57), and set that property per instance in the markup or with a modifier class. The keyframes and the geometry stay shared.Are CSS spinners bad for performance?
transform and opacity are handled by the compositor, so a spinner runs off the main thread and does not block anything the page is doing. Animating height or width is a different matter, because those force layout on every frame.