CSS Snippets

Copy. Paste. Ship.

Home › Code Snippets › CSS › Loaders

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

1. Dots — staggered scale from 0

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.

2. Spinner — a rotating border

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.

3. Pulse — scale + opacity

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.

4. Bars — height animation (with a caveat)

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.

5. Dual ring — two pseudo-elements

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.

6. Skeleton — gradient slide

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.

Loader frozen on slow JS load

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.

Loader flashes on fast connections

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; } }
Reduced motion users

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;
  }
}
Loader stacking context issue

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.

Spinner on dark AND light backgrounds

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;
  }
}
Loading state that never resolves

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());
Flexbox centering breaks

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.

Skeleton content mismatch

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?
Dotstransform: scale✓ Yes
Spinnertransform: rotate✓ Yes
Pulsetransform, opacity✓ Yes
Barsheight⚠ Layout cost
Ringtransform: rotate✓ Yes
Skeletonbackground-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

❓ 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.

🔗 Related snippets

🌐 External resources

← All CSS Snippets