CSS Loading Spinners: 12 Variants From Dots to Rings to Bars
Build 12 CSS loading spinners from scratch — dots, rings, bars, and more — with pure CSS and React. Copy-paste ready, no dependencies needed.
Why Loading Spinners Still Matter
You've seen a blank white screen kill a conversion. Users don't know if your app crashed, if their click registered, or if they're just waiting. A spinner — even a dumb, static one — tells them something is happening. That's the whole job.
In practice, a spinner reduces perceived wait time. Studies from the Nielsen Norman Group dating back to the early 2000s consistently show that a visual indicator can make 3-second waits feel like 1.5-second ones. People are irrational about time when they're anxious. Give them something to watch.
The tricky part is picking the right variant for your UI. A bouncing dot trio feels playful and suits consumer apps. A thin ring spinner looks clinical and professional — you'd use it in a dashboard or data table. Bars feel rhythmic, almost musical, and land well in media players or upload flows. Worth noting: the wrong spinner style can subtly break the personality of your whole design system, so the choice matters more than it seems.
This guide covers 12 variants, all pure CSS (with React wrappers where it helps readability). No libraries, no dependencies, no npm install spinner-package-that-hasn't-been-updated-since-2022. Let's go.
The Foundation: CSS Animations You'll Actually Use
Before the code samples, a quick primer on the two @keyframes patterns that power almost everything here. The first is rotate — a full 360-degree spin. The second is scale or opacity pulsing, where an element breathes in and out. Every spinner in this guide derives from one or both of those.
Two CSS properties do most of the work: animation-timing-function and animation-delay. Timing functions like cubic-bezier(0.65, 0, 0.35, 1) give you that satisfying ease-in-out snap rather than the robotic linearity of linear. Staggered animation-delay values on sibling elements — think -0.2s, -0.4s, -0.6s — create the cascading wave effect in dot and bar spinners without any JavaScript.
Quick aside: always set animation-fill-mode: both when you use negative delays. Without it, elements flash to their end state before the animation starts, which causes an ugly frame-0 flicker that's basically impossible to catch in dev tools.
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 0.2; transform: scale(0.75); }
50% { opacity: 1; transform: scale(1); }
}
/* Reuse these everywhere */
.spinner-ring {
animation: spin 0.9s linear infinite;
}
.dot {
animation: pulse 1.2s ease-in-out infinite both;
}One more thing — will-change: transform on spinning elements moves them to their own compositor layer on Chrome and Safari, which keeps the main thread free. Use it only on elements that are actively animating; slapping it on everything is worse than not using it at all.
Variants 1–4: The Ring Family
Rings are the workhorse of the spinner world. They're neutral, they scale from 16px to 64px without looking broken, and they read as 'professional' regardless of color scheme. Here are four takes on the ring pattern.
Variant 1 — Classic border ring. The oldest trick in the book. Set three sides of a border to a muted color, one side to your accent, then spin. Dead simple, works everywhere since CSS3.
.ring-classic {
width: 40px;
height: 40px;
border-radius: 50%;
border: 4px solid rgba(99, 102, 241, 0.2);
border-top-color: #6366f1;
animation: spin 0.8s linear infinite;
}Variant 2 — Gradient ring. You can't apply a gradient directly to border-color, but you can fake it. Wrap the ring in a container, use a conic-gradient on the container, then mask the center with a background: white circle on the inner element. This gives you a smooth color fade around the circumference — popular in 2024-era design systems and still looking fresh.
.ring-gradient {
width: 44px;
height: 44px;
border-radius: 50%;
background: conic-gradient(from 0deg, transparent 0%, #6366f1 100%);
animation: spin 1s linear infinite;
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #fff 0);
mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #fff 0);
}Variant 3 — Double ring (counter-rotation). Two concentric rings, one spinning clockwise at 0.9s, the other counter-clockwise at 1.4s. The overlapping motion looks hypnotic. Keep the outer ring at 48px, inner at 32px, and make sure their border widths differ (4px vs 3px) so the gap between them reads clearly. Variant 4 — Dashed ring. Set border-style: dashed and use border-width: 3px. The dashes rotate and create a secondary motion that feels almost mechanical. Combine with animation-timing-function: steps(8, end) for a ticking effect instead of a smooth spin. These four ring variants alone could cover 80% of your UI needs if you're running a design system built around consistency.
Variants 5–8: Dots
Dots are where CSS spinners get expressive. You're working with tiny circles that bounce, pulse, or chase each other — and the personality you inject through timing curves is everything. Honestly, a badly-timed dot spinner looks more broken than no spinner at all, so pay attention to the cubic-bezier values here.
Variant 5 — Three-dot bounce. Three 10px circles lined up horizontally, each translating up by 12px on a staggered delay. Classic. Used by Facebook, Slack, and a hundred other products because it works. The key is the timing: cubic-bezier(0.45, 0.05, 0.55, 0.95) gives it that rubber-ball snap.
.dots {
display: flex;
gap: 8px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #6366f1;
animation: bounce 1.2s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite both;
}
.dot:nth-child(2) { animation-delay: -0.4s; }
.dot:nth-child(3) { animation-delay: -0.2s; }
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}Variant 6 — Pulsing dot (single). One larger dot, 20px, that scales from 0.6 to 1.0 and fades opacity simultaneously. It's dead simple but lands surprisingly well as an inline indicator next to text ('Saving...' with a softly breathing dot). Variant 7 — Dot trail / chasing dots. Four dots arranged in a circle using transform: rotate(Ndeg) translateX(18px). Each rotates at a slightly different speed, creating a comet-like trail effect. You'd need either careful transform-origin math or a small JS wrapper, but the result is worth it — it's one of the few spinners that looks genuinely custom without a design tool.
Variant 8 — Elastic dot (morphing). A single dot that morphs between a circle and an oval using border-radius keyframes while translating side to side. It has a cartoony feel that suits consumer apps, game UIs, or anything with a playful brand — think the kind of UI you'd find alongside claymorphism components. You want something more serious? Use a ring. You want something with character? This is your variant.
Variants 9–11: Bars and Grids
Bars feel structured. They imply progress even when there's no actual progress value to display, which is why you see them in audio editors, terminal UIs, and anywhere that needs to signal 'processing' rather than just 'waiting'. Grid spinners are their close cousin — a matrix of cells that fill and empty in sequence.
Variant 9 — Equalizer bars. Five 4px-wide, 24px-tall bars. Each scales vertically from 0.4 to 1.0 height with a staggered delay. The transform-origin: bottom center line is non-negotiable — without it, bars shrink from the middle, which looks wrong.
.bars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 28px;
}
.bar {
width: 4px;
height: 100%;
background: #6366f1;
border-radius: 2px;
transform-origin: bottom center;
animation: equalizer 1s ease-in-out infinite both;
}
.bar:nth-child(1) { animation-delay: -0.8s; }
.bar:nth-child(2) { animation-delay: -0.6s; }
.bar:nth-child(3) { animation-delay: -0.4s; }
.bar:nth-child(4) { animation-delay: -0.2s; }
@keyframes equalizer {
0%, 100% { transform: scaleY(0.4); }
50% { transform: scaleY(1); }
}Variant 10 — Fill bars (horizontal stack). Three 32px-wide bars stacked vertically, each filling from left to right with an offset delay. Unlike equalizer bars, these animate width (or better, scaleX from transform-origin: left center). Use it in forms, file-upload UIs, or anywhere you want to imply sequential steps. Variant 11 — 3x3 grid pulse. Nine 8px squares arranged in a CSS Grid, each pulsing opacity from 0.1 to 1.0 in a wave pattern. The delay math is a bit tedious by hand — you'd normally generate the nine nth-child selectors with a Sass loop or a small JavaScript render function. In React it's actually cleaner:
const GridSpinner = () => (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 8px)',
gap: '4px'
}}>
{Array.from({ length: 9 }, (_, i) => (
<div
key={i}
style={{
width: 8, height: 8,
borderRadius: 2,
background: '#6366f1',
animation: `pulse 1.2s ease-in-out infinite both`,
animationDelay: `${-1.2 + i * 0.133}s`
}}
/>
))}
</div>
);That React pattern — generating animation delays programmatically — is worth keeping in your toolkit. It works identically for dot trails and bar sequences. Way cleaner than writing 9 nth-child rules by hand, and it gives you a single number to tweak when you want to speed up or slow down the cascade.
Variant 12: The Morph Spinner (SVG + CSS)
Variant 12 is the one people screenshot. It's an SVG circle with a stroke-dasharray that animates from a short arc to a nearly-complete ring while the whole thing rotates. You've seen this in Google's Material Design since 2015 — it's called the 'indeterminate circular progress' pattern and it's genuinely beautiful when done right.
The key numbers: stroke-dasharray: 80 200 at the start (short arc, long gap), animating to stroke-dasharray: 200 200 at 50% (nearly full circle), then back to 80 200 at 100%. Combine that with stroke-dashoffset animating from 0 to -120px so the arc appears to travel around the ring, and layer a second rotate animation on the SVG container itself.
const MorphSpinner = ({ size = 44, color = '#6366f1' }) => (
<svg
width={size}
height={size}
viewBox="0 0 44 44"
style={{ animation: 'spin 1.4s linear infinite' }}
>
<circle
cx="22" cy="22" r="18"
fill="none"
stroke={color}
strokeWidth="4"
strokeLinecap="round"
style={{
animation:
'morph-dash 1.4s ease-in-out infinite',
transformOrigin: 'center'
}}
/>
</svg>
);
/* In your global CSS: */
@keyframes morph-dash {
0% { stroke-dasharray: 1, 150; stroke-dashoffset: 0; }
50% { stroke-dasharray: 90, 150; stroke-dashoffset: -35px; }
100% { stroke-dasharray: 90, 150; stroke-dashoffset: -124px; }
}Look, this one requires SVG knowledge that the pure-CSS variants don't. But the payoff is a spinner that doesn't feel like a template. It's the variant you reach for when the rest of the UI is polished — maybe sitting inside a glassmorphism modal or on a premium checkout page. If you're investing in UI quality across your whole design system, pair it with something like the box shadow generator to nail the card depths behind it.
One more thing — wrap all 12 of these in a single <Spinner variant="morph" size={40} color="currentColor" /> component with a variant prop. You don't want 12 separate components scattered through your codebase. Make it one composable abstraction, export it from your component library entry point, and never think about it again.
Accessibility and Performance Checklist
A spinner that causes layout shift, burns the GPU, or blocks screen readers isn't a UX improvement — it's a bug in a costume. Here's what to actually check before you ship any of these.
First: aria-label and role="status". Screen readers need to know a spinner exists and what it means. Add role="status" to the spinner container and aria-label="Loading" (or something context-specific like aria-label="Submitting form"). If you're announcing a state change dynamically, use aria-live="polite" on the container so screen readers pick up the change without interrupting the user mid-sentence.
<div
role="status"
aria-label="Loading"
aria-live="polite"
className="spinner-ring"
/>Second: prefers-reduced-motion. Some users have this set for medical reasons — vestibular disorders, epilepsy, motion sickness. Respect it. A two-line media query that drops the spinner to a simple fade-pulse instead of a full rotation can make your app usable for people who'd otherwise have to leave. Also worth checking: all 12 variants above use transform and opacity for animation, which are the two GPU-composited properties. Don't animate width, height, margin, or top inside a spinner — those trigger layout recalculation on every frame.
@media (prefers-reduced-motion: reduce) {
.spinner-ring,
.dot,
.bar {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}Third: remove spinners from the DOM when loading completes. Don't hide them with display: none while leaving them mounted — browsers can still run @keyframes on invisible elements in some rendering modes. Unmount or conditionally render them in React. That's it. Not complex. Just easy to forget when you're shipping fast.
FAQ
The classic border ring (Variant 1) works in every browser that supports CSS animations — that's basically everything since IE10. The gradient ring using conic-gradient and mask needs Chrome 69+, Safari 12.1+, and Firefox 83+, so check your analytics before using it if you support older environments.
Pass color as a prop and use currentColor as the default. Set the color on the spinner's container element via a CSS variable (--spinner-color: #yourcolor) or inline style, then reference currentColor in the CSS. That way the spinner inherits whatever text color its parent has, which usually does the right thing automatically.
Use a skeleton when you know the shape of the content that's loading — cards, lists, text blocks. Use a spinner for actions where there's no 'shape' to preview: form submission, authentication, file upload. Mixing them — a spinner inside a skeleton layout — usually looks wrong and confuses users about what's actually loading.
Reserve the space before the spinner mounts. Give the spinner container a fixed width and height (or min-height) so the page doesn't reflow when it appears or disappears. Also set overflow: hidden on the container if you're using variants that scale beyond their natural bounds during animation.