Part of our complete guide: CSS Snippets →
UI Component · HTML + CSS
CSS Loaders & Spinners
6 loading animations for showing progress. Pure CSS, no JavaScript. Copy, paste, and customize colors and timing.
Last updated: October 2026 · 7 min read
These loaders use pure CSS @keyframes animations. No JavaScript needed. Copy the HTML + CSS, paste into your project, and change the colors to match your brand.
What you get: 6 loader patterns — dots, spinner, pulse, bars, dual ring, and skeleton. Each uses GPU-accelerated properties for smooth 60fps animation.
Why pure CSS for loaders
Loader animations run constantly — from the moment a page starts loading to the moment data arrives. They're the highest-visibility UI element in any app. If they're poorly implemented, users notice immediately.
Pure CSS loaders win on three fronts. First, they're tiny — 200 bytes of CSS vs 5–15KB for a JS animation library. Second, they run on the GPU compositor, not the JavaScript thread — so they never freeze even when your app is busy parsing data. Third, they work before JavaScript loads, which means the user sees the loader immediately instead of a blank screen for 300ms.
The trade-off: pure CSS loaders can't be paused by JavaScript. If you need programmatic control (pause on network idle, for example), you'd add a CSS class toggle — which is still simpler than a full JS animation library.
🎬 Live Loaders
All 6 animate automatically. Click "Copy" to grab the code for each.
⚫ Dots
<div class="loader-dots">
<span></span><span></span><span></span>
</div>
<style>
.loader-dots { display: flex; gap: 8px; }
.loader-dots span {
width: 12px; height: 12px;
background: #3b82f6; border-radius: 50%;
animation: dot-bounce 1.4s infinite ease-in-out both;
}
.loader-dots span:nth-child(1) { animation-delay: -0.32s; }
.loader-dots span:nth-child(2) { animation-delay: -0.16s; }
.loader-dots span:nth-child(3) { animation-delay: 0s; }
@keyframes dot-bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
</style>
🔄 Spinner
<div class="loader-spinner"></div>
<style>
.loader-spinner {
width: 40px; height: 40px;
border: 4px solid #1e293b;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
💓 Pulse
<div class="loader-pulse"></div>
<style>
.loader-pulse {
width: 48px; height: 48px;
background: #3b82f6; border-radius: 50%;
animation: pulse-scale 1.2s ease-in-out infinite;
}
@keyframes pulse-scale {
0%, 100% { transform: scale(0.6); opacity: 0.5; }
50% { transform: scale(1); opacity: 1; }
}
</style>
📊 Bars
<div class="loader-bars">
<span></span><span></span><span></span><span></span>
</div>
<style>
.loader-bars {
display: flex; gap: 4px;
align-items: flex-end; height: 40px;
}
.loader-bars span {
width: 6px; background: #3b82f6;
border-radius: 2px;
animation: bar-bounce 1.2s ease-in-out infinite;
}
.loader-bars span:nth-child(1) { animation-delay: 0s; }
.loader-bars span:nth-child(2) { animation-delay: 0.15s; }
.loader-bars span:nth-child(3) { animation-delay: 0.3s; }
.loader-bars span:nth-child(4) { animation-delay: 0.45s; }
@keyframes bar-bounce {
0%, 100% { height: 12px; }
50% { height: 40px; }
}
</style>
💫 Dual Ring
<div class="loader-ring"></div>
<style>
.loader-ring {
position: relative;
width: 48px; height: 48px;
}
.loader-ring::before,
.loader-ring::after {
content: ''; position: absolute; inset: 0;
border-radius: 50%;
border: 3px solid transparent;
}
.loader-ring::before {
border-top-color: #3b82f6;
animation: spin 1s linear infinite;
}
.loader-ring::after {
border-bottom-color: #8b5cf6;
animation: spin 1.5s linear infinite reverse;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
🦴 Skeleton
<div class="loader-skeleton"></div>
<div class="loader-skeleton thin"></div>
<style>
.loader-skeleton {
width: 200px; height: 16px;
border-radius: 8px;
background: linear-gradient(90deg,
#1e293b 25%, #334155 50%, #1e293b 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.loader-skeleton.thin {
height: 10px; margin-top: 8px; width: 140px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
How the animations work
Each dot scales from 0 to 1 and back. The animation-delay values (-0.32s, -0.16s, 0s) make them ripple in sequence rather than all at once. Negative delays mean the animation starts already in progress — a common trick for staggered effects that doesn't need JavaScript.
The border trick: three sides are transparent, one side is colored. Rotating the element makes it look like a curved loading arc. It's the classic spinner pattern — cheap, smooth, and works everywhere.
Two properties animate together: transform: scale() makes the circle grow, opacity makes it fade. The combination creates a breathing effect that reads as "active" without being distracting.
Bars animate height — which is not GPU-accelerated. For most cases this is fine, but if you need 60fps on a low-end device, rewrite with transform: scaleY() instead. The height animation is easier to read and works for typical use cases.
Two ::before and ::after elements, each with a different colored border side, rotating at different speeds (1s vs 1.5s) and directions (one reversed). The overlap creates a rich visual that's still only 2 DOM elements.
The "shimmer" effect is a linear gradient moving horizontally via background-position. The gradient has three stops: dark, light, dark. As it slides across, the light section sweeps through, creating the illusion of light traveling across the skeleton.
🔬 Edge cases
The cases most tutorials skip. All of these trip up real developers in production.
If your JavaScript is heavy and blocks the main thread, CSS animations may briefly freeze. They run on the compositor thread, so most stay smooth — but if your JS is blocking CSS parsing itself, nothing renders. Fix: defer non-critical JS, use async/defer on script tags.
On fast connections, the loader can appear and disappear in 50ms — creating a jarring flash. Fix: delay the loader's appearance by 200–300ms using animation-delay: 0.3s, or toggle a class that adds a transition opacity fade-in.
.loader { opacity: 0; animation: fadeIn 0.3s 0.2s forwards; }
@keyframes fadeIn { to { opacity: 1; } }
Some users enable "reduce motion" at the OS level. All animations should be wrapped in a media query that respects it:
@media (prefers-reduced-motion: reduce) {
.loader-dots span,
.loader-spinner,
.loader-pulse,
.loader-bars span,
.loader-ring::before,
.loader-ring::after,
.loader-skeleton {
animation: none;
}
}
If the loader is inside a modal, dropdown, or any element with transform applied, the loader's position: fixed (if used) breaks. Fix: use position: absolute with a relative parent, or check the ancestor's transform stack.
The spinner uses a border trick that requires contrasting colors. If your site has both light and dark modes, the loader needs a theme-aware variant:
@media (prefers-color-scheme: dark) {
.loader-spinner {
border-color: #334155;
border-top-color: #60a5fa;
}
}
If your fetch hangs or times out, the loader spins forever. Add a timeout wrapper:
const controller = new AbortController();
setTimeout(() => controller.abort(), 10000);
fetch(url, { signal: controller.signal })
.finally(() => hideLoader());
The demo uses display: flex; align-items: center; justify-content: center on the wrapper. If your app wraps the loader in something else, add flex-shrink: 0 to the loader to prevent it collapsing to 0px in tight flex containers.
The skeleton should match the dimensions of the real content it replaces. If the skeleton is 200px wide and the actual text is 300px, the layout will jump when the content loads. Size skeletons to the real content.
⚡ Performance
| Loader | Animates | 60fps? |
|---|---|---|
| Dots | transform: scale | ✓ Yes |
| Spinner | transform: rotate | ✓ Yes |
| Pulse | transform, opacity | ✓ Yes |
| Bars | height | ⚠ Layout cost |
| Ring | transform: rotate | ✓ Yes |
| Skeleton | background-position | ✓ Yes |
The one weak spot: bars. Animating height causes layout reflow on every frame. It's fine for small loaders (under 30px) but janky on larger ones. If you need it smooth at any size, replace height with transform: scaleY().
⚠️ Common mistakes
- Forgetting to hide the loader. If the fetch errors, the loader spins forever. Always wrap in
.finally()and hide it, even on failure. - Making the loader too big. A loader should be subtle. Anything above 60px reads as "something is very wrong."
- Animating the loader's container. Animating parent
marginorpaddingtriggers layout. Animate only the loader element itself. - Using
animation-iteration-count: infinitewith no delay for content that loads fast. The flash is jarring. Delay the animation by 200ms. - Skipping reduced-motion support. Accessibility issue. Some users get motion sickness.
- Using JS animation libraries for simple loaders. A 5KB CSS loader beats a 40KB JS library every time.
- Forgetting ARIA labels. Add
role="status"andaria-label="Loading"to the wrapper for screen readers. - Sizing the skeleton incorrectly. If the skeleton is 200px and the real content is 320px, the layout jumps when data loads. Size skeletons to match the real content.
❓ Frequently asked questions
Do these CSS loaders require JavaScript?
No. All 6 loaders are pure CSS using @keyframes animations. No JavaScript is needed to display or animate them.
Which CSS loader is best for performance?
The spinner and pulse loaders are the most performant. They animate transform properties, which are GPU-accelerated. Avoid loaders that animate width, height, or margins.
How do I show the loader only when data is loading?
Toggle a CSS class on a wrapper element. When your fetch starts, add .is-loading. When it finishes, remove it. The loader shows/hides purely through CSS display rules.
Can I change the loader colors and sizes?
Yes. All colors are set in the CSS. Change the background or border-color values. Sizes are controlled by width, height, or font-size on the loader element.
Will these loaders work on mobile?
Yes. Pure CSS animations work everywhere — mobile, desktop, and tablets. They don't depend on hover, so they behave identically across devices.
Are these loaders accessible?
Add aria-label='Loading' and role='status' to the wrapper for screen readers. The animations themselves are decorative and shouldn't convey information independently.
How do I add reduced motion support?
Wrap your animation rules in @media (prefers-reduced-motion: no-preference) or provide a static fallback for users who've enabled 'reduce motion' at the OS level.
Can I use these loaders in React or Vue?
Yes. They're plain HTML/CSS. In React, use className instead of class. In Vue, use :class bindings. The CSS itself needs no changes.