A CSS loading animation is one of the most commonly needed UI components — and also one of the most over-engineered. Most use cases need just one HTML element, 8–15 lines of CSS, and zero JavaScript. This guide covers the 12 most useful CSS loader patterns, how to build them from scratch, accessibility requirements, reduced-motion support, and how to use them in React, Vue, and Svelte.

Want to skip the theory and generate code? Use our CSS Loader Generator to customize any pattern visually and copy the code in one click.

The CSS Spinner: Foundation Pattern

The spinner is the industry default for loading states. It's built from a single element using the border trick — three transparent sides and one colored side create an arc that appears to rotate:

.spinner {
  width: 48px;
  height: 48px;
  border: 4px solid rgba(124, 111, 255, 0.2); /* track */
  border-top-color: #7c6fff;                  /* arc */
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}
@keyframes spin {
  to { transform: rotate(360deg); }
}

The transparent rgba border creates a visible "track" so the arc appears to move against a background. For a thinner track, use an even lower alpha value. For no track at all, use border-color: transparent on three sides.

Ring Loader (Dual-Line Variant)

A ring loader colors the top and bottom sides, creating two arc segments that chase each other around the circle:

.ring {
  width: 48px;
  height: 48px;
  border: 4px solid transparent;
  border-top-color: #7c6fff;
  border-bottom-color: #7c6fff;
  border-radius: 50%;
  animation: spin 0.9s linear infinite;
}

Bouncing Dots

Three dots that scale in and out in sequence. The key is using negative animation delays so the animation appears to already be in progress when the page loads — without negative delays, all three dots start at the same time, creating a single-pulse illusion:

.dots {
  display: flex;
  gap: 8px;
  align-items: center;
}
.dots div {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #7c6fff;
  animation: bounce 1.2s ease-in-out infinite both;
}
.dots div:nth-child(1) { animation-delay: -0.32s; }
.dots div:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
  0%, 80%, 100% { transform: scale(0); }
  40% { transform: scale(1); }
}

Bar / Equalizer Loader

Four or five vertical bars that scale on the Y axis, creating an audio equalizer effect. Perfect for data loading and music players:

.bars {
  display: flex;
  gap: 4px;
  align-items: center;
  height: 48px;
}
.bars div {
  width: 6px;
  height: 100%;
  background: #7c6fff;
  border-radius: 2px;
  animation: bars 1.2s ease-in-out infinite;
}
.bars div:nth-child(1) { animation-delay: -0.45s; }
.bars div:nth-child(2) { animation-delay: -0.3s; }
.bars div:nth-child(3) { animation-delay: -0.15s; }
@keyframes bars {
  0%, 80%, 100% { transform: scaleY(0.4); }
  40% { transform: scaleY(1); }
}

Ripple Loader

Two concentric circles that expand outward from the center and fade out, like a drop in water. Uses absolute positioning so both circles overlap:

.ripple {
  position: relative;
  width: 48px;
  height: 48px;
}
.ripple div {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 3px solid #7c6fff;
  animation: ripple 1s ease-out infinite;
}
.ripple div:nth-child(2) {
  animation-delay: 0.5s;
}
@keyframes ripple {
  0%  { transform: scale(0); opacity: 1; }
  100% { transform: scale(1); opacity: 0; }
}

Generate CSS loaders visually

12 styles, customizable color, size and speed. Copy HTML + CSS in one click.

Open CSS Loader Generator →

Accessibility: The Part Most Developers Skip

A CSS loader is invisible to screen readers unless you explicitly add ARIA. A user with a screen reader navigating a page with a spinning div will hear nothing — they won't know a process is running. This is an accessibility failure.

The Correct Pattern

<div role="status" aria-label="Loading">
  <div class="css-loader" aria-hidden="true"></div>
  <span class="visually-hidden">Loading...</span>
</div>

/* CSS for the hidden label */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

role="status" announces the element's content to screen readers politely (it waits for the user to finish their current task). Use role="alert" for urgent messages that should interrupt. The aria-hidden="true" on the visual spinner prevents it from being announced separately. The .visually-hidden text provides the accessible label.

💡

Live region tip: When the loading process completes, update the status element's text to "Loading complete" or remove it. This announces completion to screen reader users, closing the loop on the interaction.

Reduced Motion Support

Some users have prefers-reduced-motion: reduce set in their OS (System Preferences → Accessibility on Mac, Display → Reduce Motion on iOS). For people with vestibular disorders, spinning and pulsing animations can cause real physical discomfort. Always provide a fallback:

/* Default — animated loader */
.css-loader {
  animation: spin 0.8s linear infinite;
}

/* Reduced motion — simple fade instead */
@media (prefers-reduced-motion: reduce) {
  .css-loader {
    animation: none;
    opacity: 0.6;
    /* Or: replace with a static indicator */
  }
}

Using CSS Loaders in React

CSS loaders work identically in React — just use className instead of class and import your CSS file or module:

// Spinner.jsx
import './Spinner.css';

function Spinner({ label = 'Loading' }) {
  return (
    <div role="status" aria-label={label}>
      <div className="css-loader" aria-hidden="true" />
      <span className="visually-hidden">{label}</span>
    </div>
  );
}

export default Spinner;

For dynamic color or size, use inline styles with CSS custom properties:

// Override custom properties per-instance
<div
  className="css-loader"
  style={{ '--loader-color': brandColor, '--loader-size': '24px' }}
/>

Using CSS Loaders in Vue

<template>
  <div role="status" :aria-label="label">
    <div class="css-loader" aria-hidden="true" />
    <span class="visually-hidden">{{ label }}</span>
  </div>
</template>

<script setup>
const props = defineProps({ label: { default: 'Loading' } });
</script>

<style scoped>
.css-loader {
  width: 48px;
  height: 48px;
  border: 4px solid rgba(124,111,255,0.2);
  border-top-color: #7c6fff;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>

Choosing the Right Loader for the Context

Frequently Asked Questions

Should I use a CSS loader or a GIF/SVG animation?
For the patterns covered here, CSS is better: no extra HTTP request, no resolution issues on retina displays, instant color/size changes via CSS custom properties, and accessible by adding ARIA attributes. Use SVG for complex multi-path animations that CSS @keyframes can't express cleanly (morphing shapes, etc.), or Lottie for designer-created animations.
How do I show/hide a CSS loader with JavaScript?
The cleanest approach is toggling a class. Add .hidden { display: none; } to your CSS, then spinner.classList.add('hidden') when done. Alternatively, toggle visibility: hidden; opacity: 0; with a transition for a fade-out effect. Don't just set display: none directly — it's hard to transition.
Can I use CSS loaders in a button?
Yes — make the button position: relative and use an ::after pseudo-element for the spinner, or include a small spinner element inside the button HTML. When loading, disable the button with disabled or pointer-events: none to prevent double-submit.
What size should a CSS loader be?
As a rule: full-page loaders 48–64px, card/section loaders 32–48px, inline/button loaders 16–20px. The loader should be proportional to the content area it's blocking — too small and it's missed, too large and it dominates the layout.

Ready to generate a loader with the exact color and style you need? Use the CSS Loader Generator. For more animation techniques, see the CSS Animation Generator and the CSS animation performance guide.