TH The Loading Loop
Spinner Recipes

CSS Spinner Examples: Five Copyable Loaders

CSS Spinner Examples: Five Copyable Loaders
tldrThe most useful CSS spinner examples are a ring for general waits, a sliding line for page or panel loading, three dots beside compact text, a pulse for quiet background work, and a progress bar when the application knows a real percentage. Each example here uses minimal markup and animates transform or opacity. Add visible or screen-reader status text, then stop or replace decorative motion for people who prefer reduced motion.

Five CSS spinner examples you can copy

If you need a loader today, pick the shape that matches what the interface actually knows. Use a ring, dots, or a pulse for an indeterminate wait: the task is running, but there is no trustworthy percentage. Use a real progress bar when the application can calculate completion. A sliding line works well in a narrow page or form header. The five patterns below use valid HTML and CSS, animate only transform and opacity, and include one shared accessibility layer.

Here is the short version:

Pattern Best for What it communicates
Ring General-purpose waits Work is continuing
Sliding line Page or panel refresh A section is loading
Three dots Messages and compact controls A short response is pending
Pulse Quiet background work Activity without urgency
Progress bar Uploads, imports, multi-step jobs A measured fraction is complete

When you are unsure, use the ring. It is familiar, compact, and does not pretend to know how long the task will take.

Use one accessible loading wrapper

The animation is decoration. The status message carries the meaning. Start with this wrapper and put one of the visual patterns inside it:

<div class="loading-indicator" role="status">
  <span class="loader-ring" aria-hidden="true"></span>
  <span class="visually-hidden">Loading results...</span>
</div>
.loading-indicator {
  display: inline-flex;
  align-items: center;
  gap: 0.625rem;
}

.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;
}

The aria-hidden="true" attribute keeps the decorative shape out of the accessibility tree. The text remains available to screen readers. Under WAI-ARIA 1.2, role="status" has implicit polite live-region behavior, so a status can be announced without moving focus. Do not repeatedly replace the text on every animation frame.

If a particular content region is being updated, set aria-busy="true" on that region while the update is in progress, then restore aria-busy="false" when the update is complete. The ARIA specification explains that assistive technology may hold changes while a region is busy and expose them together after it becomes available. The site’s accessible loading indicators guide covers the full state-management pattern.

Example 1: a dependable ring spinner

This is the useful default: one element, one animation, and three variables you can change without rebuilding it.

<span class="loader-ring" aria-hidden="true"></span>
.loader-ring {
  --loader-size: 2rem;
  --loader-width: 0.25rem;
  --loader-color: #2563eb;

  display: inline-block;
  width: var(--loader-size);
  aspect-ratio: 1;
  border: var(--loader-width) solid #dbeafe;
  border-top-color: var(--loader-color);
  border-radius: 50%;
  animation: ring-turn 0.8s linear infinite;
}

@keyframes ring-turn {
  to {
    transform: rotate(1turn);
  }
}

The pale border draws the full track. The differently coloured top edge becomes the moving arc when the element rotates. Change --loader-size for the diameter, --loader-width for thickness, and --loader-color for the active arc.

Keep the border width comfortably below half the radius; otherwise the centre closes and the spinner becomes a disc. The aspect-ratio: 1 declaration keeps the box square when the size changes. For a deeper explanation of alignment, border construction, and stopping the element, use the single-ring CSS loading spinner recipe.

Example 2: a sliding line loader

A line loader fits the top of a page, a table, or the edge of a form panel. The track clips a shorter bar while that bar travels across it.

<span class="loader-line" aria-hidden="true"></span>
.loader-line {
  display: block;
  position: relative;
  width: min(24rem, 100%);
  height: 0.25rem;
  overflow: hidden;
  border-radius: 999px;
  background: #dbeafe;
}

.loader-line::before {
  content: "";
  position: absolute;
  inset: 0 auto 0 0;
  width: 40%;
  border-radius: inherit;
  background: #2563eb;
  animation: line-sweep 1.1s ease-in-out infinite;
}

@keyframes line-sweep {
  from {
    transform: translateX(-110%);
  }
  to {
    transform: translateX(360%);
  }
}

Why 360%? Percentage translation is based on the moving bar’s own width. The bar is 40% as wide as the track, so it needs to travel several of its own widths to clear the far edge. The track’s overflow: hidden removes the bar before and after each pass.

This remains indeterminate. The sweep means “still working,” not “40% complete.” Do not use the moving bar’s position as a percentage. If the application has measured progress, use the final example instead.

Example 3: three staggered dots

Dots work neatly beside “Sending” or “Checking” because they occupy little horizontal space. Use three identical spans and stagger the same animation with delays.

<span class="loader-dots" aria-hidden="true">
  <span></span>
  <span></span>
  <span></span>
</span>
.loader-dots {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
}

.loader-dots > span {
  width: 0.5rem;
  aspect-ratio: 1;
  border-radius: 50%;
  background: #2563eb;
  animation: dot-lift 0.9s ease-in-out infinite alternate;
}

.loader-dots > span:nth-child(2) {
  animation-delay: 0.15s;
}

.loader-dots > span:nth-child(3) {
  animation-delay: 0.3s;
}

@keyframes dot-lift {
  from {
    transform: translateY(0);
    opacity: 0.45;
  }
  to {
    transform: translateY(-0.35rem);
    opacity: 1;
  }
}

The dots share duration and easing. Only the delays differ, which creates the sequence. Keep the vertical movement small enough that the loader does not change the line box or collide with nearby text. The transform changes what is drawn without moving surrounding layout.

For variations that use a static centre line, different timing, or a single reusable class, see the three-dot loader recipe.

Example 4: a quiet pulse

A pulse is useful when rotation feels too prominent: background synchronisation, a compact toolbar state, or a wait that should stay visible without taking over the page.

<span class="loader-pulse" aria-hidden="true"></span>
.loader-pulse {
  display: inline-block;
  width: 1rem;
  aspect-ratio: 1;
  border-radius: 50%;
  background: #2563eb;
  animation: pulse-softly 1.4s ease-in-out infinite;
}

@keyframes pulse-softly {
  0%, 100% {
    transform: scale(0.8);
    opacity: 0.45;
  }
  50% {
    transform: scale(1);
    opacity: 1;
  }
}

The long duration and ease-in-out timing avoid a sharp visual jump. Both endpoints match, so the loop joins cleanly. Scaling from 0.8 to 1 keeps the pulse restrained; large repeated zooms are harder to ignore and may be uncomfortable.

A pulse is deliberately less assertive than a ring. Do not use that subtlety where a blocked form or payment action needs an unmistakable state, and never rely on the dot alone. Keep the status text in the shared wrapper.

Example 5: a measured progress bar

Use this only when your application knows the completed fraction. The bar below starts at 65%. Its visual fill and accessible value must be updated together.

<div
  class="loader-progress"
  role="progressbar"
  aria-label="Uploading file"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuenow="65"
  style="--progress: 0.65"
>
  <span class="loader-progress__fill"></span>
</div>
.loader-progress {
  width: min(24rem, 100%);
  height: 0.625rem;
  overflow: hidden;
  border-radius: 999px;
  background: #dbeafe;
}

.loader-progress__fill {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  background: #2563eb;
  transform: scaleX(var(--progress));
  transform-origin: left;
  transition: transform 180ms ease-out;
}

Scaling a full-width fill avoids repeatedly changing its layout width. A value of 0.65 gives a 65% scale from the left edge. In JavaScript, clamp the fraction between 0 and 1, set --progress to that fraction, and set aria-valuenow to the corresponding number between 0 and 100. If the total is unknown, do not invent a percentage; use the line, ring, dots, or pulse.

The native HTML progress element is also a good first choice when its semantics and available styling fit the design. Native elements should be preferred when they already provide the required meaning; WAI-ARIA explicitly advises using host-language semantics where an equivalent feature exists.

Why these examples animate transform and opacity

Each repeating recipe changes transform, opacity, or both. The progress bar transitions transform. This is a sound default because animation cost depends on the property being changed. MDN’s animation performance guide separates style, layout, paint, and composition work and warns that expensive properties can produce uneven motion.

That does not guarantee perfect performance. The number and size of animated elements, effects, other page work, device, and browser still matter. Test on an ordinary low-powered device with the real page loaded. Do not add will-change automatically; MDN describes it as a last resort because overuse can consume resources and make performance worse.

Add reduced-motion behavior once

The prefers-reduced-motion media feature detects a user request to reduce non-essential motion. MDN documents broad browser availability and explains that the reduced rule can remove, reduce, or replace motion. For this library, stop the decorative loops and leave a visible static indicator:

@media (prefers-reduced-motion: reduce) {
  .loader-ring,
  .loader-line::before,
  .loader-dots > span,
  .loader-pulse {
    animation: none;
  }

  .loader-ring {
    border-top-color: #2563eb;
  }

  .loader-line::before {
    transform: translateX(75%);
  }

  .loader-dots > span,
  .loader-pulse {
    transform: none;
    opacity: 1;
  }

  .loader-progress__fill {
    transition: none;
  }
}

The loading state does not disappear: the static shape and status text remain. Test the page with the operating system’s reduced-motion setting both on and off. Also test keyboard flow and a screen reader while the status begins, changes, finishes, and fails. A loader that spins correctly but never announces completion is not finished.

How to choose without overthinking it

Use a ring for the default, a line when the container is wide and shallow, dots near conversational text, and a pulse for low-emphasis background activity. Use progress only with a real value. Keep one visual language across the product: matching colour, stroke weight, speed, status wording, and reduced-motion treatment does more for polish than adding a sixth shape.

Remove the loader when work completes, update the controlled content, clear its busy state, and expose a useful success or error message when the outcome is not already obvious. The animation is only the visible middle of that sequence.

Check the whole loading lifecycle

A copied loader is ready only after its start and finish behavior works. Before the request begins, the content region should be usable and not marked busy. When work starts, show the status, mark the affected region busy, and prevent only actions that would genuinely conflict with the request. A page-wide overlay for a small card refresh blocks too much.

On success, render the new content, clear the busy state, and remove the loading status. On failure, replace “Loading” with a useful error and recovery action; an endless spinner is not an error message. If the request can time out, the interface, not the CSS animation, must decide when to stop waiting.

Also test what happens when two requests overlap, a response returns immediately, the network is slow, the component is removed before the response arrives, or JavaScript fails. A very fast response can make a spinner flash distractingly, so a product may choose a short display delay. That timing belongs in application logic and should never delay the actual result. The CSS recipes simply render the state they are given. An independent publication. Not affiliated with any prior owner of this domain.

FAQ

What is the simplest CSS loading spinner?

A one-element border ring is the simplest dependable spinner. Give a square element a circular border, change one border edge to the active colour, and rotate it with a CSS keyframe. Put the decorative ring inside a status wrapper with readable text. The ring shows ongoing work; it does not claim to show how much work is complete.

Should a loader use a spinner or progress bar?

Use a spinner, dots, pulse or sliding line when the duration or total work is unknown. Use a progress bar only when the application can calculate a meaningful completed fraction. Keep its visual value and accessible value synchronized. An animated bar that sweeps across a track is still indeterminate and should not be presented as a percentage.

Which CSS properties are best for loader animation?

Transform and opacity are strong defaults because they can avoid repeated layout work, although performance still depends on element size, effects, page activity, device and browser. Test the real interface on a low-powered device. Do not add will-change automatically: browser guidance treats it as a last-resort hint for a demonstrated performance problem.

Does a CSS spinner need ARIA?

The decorative shape should usually be hidden from the accessibility tree, while the loading state is conveyed by text and appropriate semantics. A status role can announce a polite update; aria-busy can mark the region being updated. For measured progress, use the native progress element or keep the progressbar value synchronized with the visual fill.

How do I support prefers-reduced-motion in a loader?

Add a prefers-reduced-motion media query that stops, reduces or replaces repeating decorative movement. Leave a visible static shape and the loading text so the state does not vanish. Remove transitions from measured progress if necessary. Test with the operating system preference enabled and disabled, and confirm that start, completion and failure remain understandable without animation.