
How to Create a Loading Spinner with Pure CSS
A loading spinner is one of the smallest pieces of UI on a page, but it carries real weight. It tells the user that their click registered, that the request is in flight, and that they should wait instead of clicking again. For years, spinners were animated GIFs or bundled icon fonts. Today you can build every common style with a single element, a few lines of CSS, and no external assets at all.
This guide walks through the building blocks (@keyframes, transform, and border tricks), then builds five different spinners you can drop into a project. Along the way we'll cover accessibility, reduced motion, sizing with custom properties, and the performance details that keep a spinner smooth even when the main thread is busy.
Why Build Spinners in CSS?
There are a few practical reasons to reach for CSS instead of an image or a component library:
- No extra requests : The spinner is part of your stylesheet, so it's available the instant the page renders. A GIF has to download first, which defeats the purpose of a loading indicator.
- Crisp at any size : CSS shapes are vector-like. They stay sharp on high-density screens and scale with a single variable.
- Themeable : Colors can come from
currentColoror custom properties, so the spinner matches light mode, dark mode, and brand colors automatically. - Smooth animation : Animations on
transformandopacitycan run on the compositor thread in most browsers, so they keep spinning even while JavaScript is parsing a large response.
The Core Building Blocks
Every spinner in this article uses the same three ingredients.
1. A @keyframes rule
Keyframes describe how a property changes over one cycle of the animation. For a rotating spinner, you only need the end state:
@keyframes spin {
to {
transform: rotate(360deg);
}
}
When you omit the from keyframe, the browser uses the element's current value (in this case, no rotation) as the starting point.
2. The animation shorthand
The shorthand attaches the keyframes to an element and controls timing:
.spinner {
animation: spin 0.8s linear infinite;
}
That reads as: run spin, take 0.8 seconds per cycle, use a constant speed, and repeat forever. linear matters here. With the default ease timing, a rotating spinner visibly speeds up and slows down each turn, which looks like it's stuttering.
3. A shape to animate
Most spinners are a square element turned into a circle with border-radius: 50%, with one part of the circle visually different so the rotation is noticeable. A perfectly uniform circle rotating looks exactly like a circle standing still.
Spinner 1: The Classic Border Ring
This is the spinner you've seen a thousand times: a faint ring with one colored segment chasing itself around.
<div class="spinner" role="status">
<span class="visually-hidden">Loading...</span>
</div>
.spinner {
--size: 48px;
--thickness: 5px;
--track: rgb(148 163 184 / 0.3);
--indicator: #38bdf8;
width: var(--size);
aspect-ratio: 1;
border: var(--thickness) solid var(--track);
border-top-color: var(--indicator);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
The trick is that each side of a border is drawn separately. Setting all four sides to a faint track color and then overriding border-top-color produces a quarter-circle arc. Because border-radius: 50% rounds the whole box, the arc curves smoothly into the track.
Want a two-segment look? Color the opposite side too:
.spinner--dual {
border-top-color: var(--indicator);
border-bottom-color: var(--indicator);
}
A useful detail: aspect-ratio: 1 means you only set the width. The height follows, and you can't accidentally end up with an oval.
Spinner 2: A Smooth Gradient Arc with conic-gradient
Border segments have hard ends. If you want an arc that fades out like a comet tail, use a conic-gradient and cut out the middle with a mask.
<div class="spinner-arc" role="status" aria-label="Loading"></div>
.spinner-arc {
--size: 56px;
--thickness: 6px;
width: var(--size);
aspect-ratio: 1;
border-radius: 50%;
background: conic-gradient(
from 0deg,
transparent 0deg,
#818cf8 330deg,
transparent 360deg
);
mask: radial-gradient(
farthest-side,
transparent calc(100% - var(--thickness)),
#000 calc(100% - var(--thickness) + 1px)
);
animation: spin 1s linear infinite;
}
Here's what each piece does:
conic-gradientpaints color around the center point, starting transparent and building up to solid indigo. That creates the tail.- The
maskuses aradial-gradientthat is transparent in the middle and opaque only in the outer ring. Anything transparent in the mask becomes invisible, so the solid disc becomes a ring. - The
+ 1pxon the second stop gives a tiny antialiased edge, which avoids a jagged inner circle.
Both conic-gradient and the unprefixed mask property are supported in all current major browsers. If you need to support older Safari versions, add -webkit-mask with the same value above the unprefixed line.
Spinner 3: Three Bouncing Dots
Not every loader needs to rotate. Dot loaders are common in chat interfaces ("the other person is typing") and inline buttons.
<div class="dots" role="status" aria-label="Loading">
<span></span>
<span></span>
<span></span>
</div>
.dots {
display: inline-flex;
gap: 6px;
align-items: center;
}
.dots span {
width: 10px;
aspect-ratio: 1;
border-radius: 50%;
background: currentColor;
animation: bounce 0.9s ease-in-out infinite;
}
.dots span:nth-child(2) {
animation-delay: 0.15s;
}
.dots span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.4;
}
40% {
transform: translateY(-8px);
opacity: 1;
}
}
The staggered animation-delay is what makes the dots feel like a wave. Each dot runs the same animation, just offset in time.
There's one subtle issue with positive delays: on the very first cycle, the second and third dots sit still for a moment before they start moving. If you want the wave to be in motion from the first frame, use negative delays instead. A negative delay starts the animation partway through its cycle:
.dots span:nth-child(1) {
animation-delay: -0.3s;
}
.dots span:nth-child(2) {
animation-delay: -0.15s;
}
Because the dots use currentColor, they take on the text color of whatever contains them. Put them inside a white-on-blue button and they're white; put them in a gray paragraph and they're gray.
Spinner 4: Pulsing Ring
A pulse is a calmer alternative to rotation, and it works well for "live" or "connecting" states.
<div class="pulse" role="status" aria-label="Connecting"></div>
.pulse {
position: relative;
width: 16px;
aspect-ratio: 1;
border-radius: 50%;
background: #34d399;
}
.pulse::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: inherit;
animation: ping 1.2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@keyframes ping {
to {
transform: scale(3);
opacity: 0;
}
}
The solid dot stays put while its ::after copy grows and fades. Because only transform and opacity change, this is cheap to render. Using inherit for the background and radius means you only have to change the color in one place.
Spinner 5: A Segmented "iOS-Style" Spinner
The segmented spinner, with a ring of bars that fade in sequence, looks complicated, but it's just one repeated element rotated at even intervals. You can build it with a custom property per bar.
<div class="segments" role="status" aria-label="Loading">
<i style="--i: 0"></i>
<i style="--i: 1"></i>
<i style="--i: 2"></i>
<i style="--i: 3"></i>
<i style="--i: 4"></i>
<i style="--i: 5"></i>
<i style="--i: 6"></i>
<i style="--i: 7"></i>
</div>
.segments {
--count: 8;
--duration: 0.8s;
position: relative;
width: 40px;
aspect-ratio: 1;
color: #94a3b8;
}
.segments i {
position: absolute;
left: calc(50% - 2px);
top: 0;
width: 4px;
height: 28%;
border-radius: 2px;
background: currentColor;
transform-origin: 50% calc(40px / 2);
transform: rotate(calc(360deg / var(--count) * var(--i)));
animation: fade var(--duration) linear infinite;
animation-delay: calc(
var(--duration) / var(--count) * var(--i) - var(--duration)
);
}
@keyframes fade {
from {
opacity: 1;
}
to {
opacity: 0.15;
}
}
Let's unpack the math:
- Each bar is positioned at the top center of the box.
transform-originis set to the center of the whole spinner (20px down from the top of each bar), so rotating a bar swings it around the spinner's middle.360deg / 8 * ispaces the bars 45 degrees apart.- The delay formula gives each bar a slightly later start, and subtracting the full duration makes every delay negative, so all bars are mid-animation on the first frame.
If you change the spinner's width, update the transform-origin value to half of it. You could also store the size in a custom property and use calc(var(--size) / 2) for both.
Making Spinners Reusable with Custom Properties
Once you have a spinner you like, expose its knobs as custom properties so the same class works everywhere:
.spinner {
--size: 2.5em;
--thickness: 0.25em;
--indicator: currentColor;
--track: color-mix(in srgb, currentColor 20%, transparent);
--speed: 0.8s;
display: inline-block;
width: var(--size);
aspect-ratio: 1;
border: var(--thickness) solid var(--track);
border-top-color: var(--indicator);
border-radius: 50%;
animation: spin var(--speed) linear infinite;
vertical-align: middle;
}
.spinner--sm {
--size: 1em;
--thickness: 2px;
}
.spinner--lg {
--size: 4rem;
--thickness: 6px;
}
Using em for size ties the spinner to the surrounding font size, which is exactly what you want inside a button. color-mix() creates a faint version of the current text color for the track, so the spinner adapts to its context without any extra color definitions. color-mix() is supported in all current major browsers; if you need older ones, set a plain rgb() fallback above it.
A spinner inside a button
A common pattern is to swap a button's label for a spinner while a form submits:
<button class="btn" aria-busy="true" disabled>
<span class="spinner spinner--sm" aria-hidden="true"></span>
Saving...
</button>
.btn {
display: inline-flex;
align-items: center;
gap: 0.5em;
padding: 0.6em 1.2em;
font: inherit;
color: #fff;
background: #2563eb;
border: 0;
border-radius: 8px;
}
.btn[aria-busy="true"] {
cursor: progress;
opacity: 0.85;
}
Keep the button's text visible ("Saving...") rather than replacing it entirely. It tells sighted users what's happening and keeps the button's width from jumping.
Accessibility: Spinners Need Words
A spinning circle means nothing to a screen reader. There are a few ways to handle this, depending on context.
Standalone loaders should use role="status", which creates a polite live region, plus a text label. Either put visually hidden text inside it, as in the first example, or use aria-label. When the loader is inserted into the page, assistive technology announces it without interrupting the user.
Loaders inside another control, like the button above, should be hidden with aria-hidden="true" because the surrounding text already explains the state. Announcing both "Saving" and "Loading" is redundant.
Regions that are updating can carry aria-busy="true" while content loads, and you remove it when the content arrives:
<section aria-busy="true" aria-live="polite">
<div class="spinner" role="status">
<span class="visually-hidden">Loading results...</span>
</div>
</section>
Keep in mind that live regions are more reliable when the container already exists in the DOM before you change its content. If you inject a brand-new element with role="status" and text at the same moment, some screen reader and browser combinations won't announce it.
Respecting Reduced Motion
Some people experience dizziness or nausea from continuous motion. The prefers-reduced-motion media query lets you tone things down. Don't remove the loader entirely, since the user still needs to know something is happening. Slow it down or replace rotation with a gentle fade:
@media (prefers-reduced-motion: reduce) {
.spinner,
.spinner-arc {
animation-duration: 2.5s;
}
.dots span,
.segments i {
animation: none;
opacity: 0.6;
}
.pulse::after {
animation: none;
}
}
A slow rotation is generally considered acceptable because it's small and doesn't move across the screen, but this is a judgment call. If your audience skews toward people who have turned the setting on, a static "Loading..." label with a subtle opacity pulse is the safest choice.
Performance Tips
A spinner runs constantly, so small inefficiencies add up.
- Animate only
transformandopacity. Animatingwidth,border-width,top, ormarginforces layout or paint on every frame. Every spinner in this article sticks to the cheap properties. - Don't leave hidden spinners animating. A spinner hidden with
opacity: 0orvisibility: hiddenmay still be animating. Usedisplay: noneor remove it from the DOM when loading finishes. - Avoid
will-changeeverywhere. Browsers already promote animated transforms to their own layer. Addingwill-change: transformto dozens of elements can waste memory. - Delay short loaders. If a request usually finishes in 200ms, a spinner that flashes for a fraction of a second is more distracting than helpful. You can delay its appearance with CSS alone:
.spinner--delayed {
opacity: 0;
animation:
spin 0.8s linear infinite,
appear 0s linear 400ms forwards;
}
@keyframes appear {
to {
opacity: 1;
}
}
The second animation has zero duration and a 400ms delay. It snaps the spinner to visible only if it's still on the page after 400ms, and forwards keeps it visible. Fast requests never show the spinner at all.
Common Pitfalls
- The spinner wobbles : If the element isn't a perfect square, or its dimensions produce subpixel values, the rotation looks off-center. Use
aspect-ratio: 1and whole-pixel oremsizes that resolve cleanly. - It stutters on each turn : You're probably using the default
easetiming. Rotation spinners needlinear. - It inherits a transform : If you already use
transformon the spinner for positioning, such astranslate(-50%, -50%)for centering, the keyframerotate()replaces it and the spinner jumps. Either wrap it in a positioned parent, or use the individualrotateproperty in the keyframes (to { rotate: 360deg; }), which combines withtransforminstead of overriding it. - It's invisible in dark mode : Hardcoded dark track colors disappear on dark backgrounds. Build track colors from
currentColorwithcolor-mix()or use semi-transparent values. - No text alternative : A spinner with no label is silent for screen reader users. Always pair it with
role="status"and text, or hide it and let nearby text carry the meaning.
Centering a Full-Page Loader
For a page-level loader that sits over content, a grid overlay is the simplest approach:
.loader-overlay {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: rgb(15 23 42 / 0.6);
z-index: 1000;
}
<div class="loader-overlay">
<div class="spinner spinner--lg" role="status">
<span class="visually-hidden">Loading page...</span>
</div>
</div>
place-items: center centers the spinner horizontally and vertically with no transforms, which also sidesteps pitfall number three.
Conclusion
A pure CSS spinner is a small project that touches a lot of fundamentals: keyframes, transforms, borders, gradients, masks, custom properties, and accessibility. Start with the classic border ring for most use cases, reach for the conic-gradient arc when you want something more polished, and use dots or pulses when rotation doesn't fit the context.
Whichever style you pick, keep the same checklist in mind: animate only transform and opacity, give the spinner a text alternative, honor prefers-reduced-motion, and remove it from the page as soon as loading is done. Do that, and your users will always know the page is working for them, even when they have to wait a second.


