cssloaderspinnercss animations

Pure CSS loading spinners: the border technique and its variants

How to create CSS loaders without JavaScript: the rotating border trick, keyframes, staggered nth-child delays, negative delay and performance notes.

August 23, 2026·7 min read

A loading spinner does not need JavaScript. No library, no GIF, no Lottie: with a div and a few keyframes you get crisp loading indicators at any resolution with minimal performance cost. This article breaks down the techniques behind the six most common spinners so you understand what each piece does.

The mother technique: the border ring

The classic spinner (GitHub's, Bootstrap's, YouTube's) exploits a CSS property few people keep in mind: an element's four borders are drawn separately. Make a circular element with a thick border and turn one side transparent:

.loader {
  width: 48px;
  height: 48px;
  border: 4px solid rgba(124, 58, 237, 0.2);
  border-bottom-color: #7c3aed;
  border-radius: 50%;
  animation: loader-rotation 1s linear infinite;
}

@keyframes loader-rotation {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

With the markup <div class="loader"></div> you get a ring with a full faint track and a vivid arc at the bottom. Rotating the whole element sweeps that arc through 360 degrees, producing the illusion of continuous spinning. Three design decisions live in these lines:

  • linear: rotation must be constant-speed. With ease the spinner would "breathe", accelerating and braking.
  • Translucent track: the same color with low alpha (8-digit hex notation, #7c3aed33) shows where the full ring is and conveys perceived progress.
  • Animate transform, not visual properties: transform composites on GPU without triggering reflow. Animating border-color or width forces the browser to recalculate layout.

The "dual ring" variant is the same idea with the entire border in solid color except border-bottom-color: transparent, creating a visible gap that makes motion more obvious.

Staggered dots: delays with nth-child

Three dots bouncing in sequence (the messaging app "typing..." pattern) uses three sibling divs running the same animation with progressive delays:

.dots {
  display: flex;
  gap: 6px;
}
.dots div {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #7c3aed;
  animation: loader-bounce 0.6s ease-in-out infinite;
}
.dots div:nth-child(2) { animation-delay: 0.1s; }
.dots div:nth-child(3) { animation-delay: 0.2s; }

@keyframes loader-bounce {
  0%, 100% { transform: scale(0.6); opacity: 0.5; }
  50% { transform: scale(1); opacity: 1; }
}

Staggering creates the perception of sequence or a traveling wave. Swap three dots for five bars and a scaleY() animation and you have the "equalizer bars" variant.

The negative delay trick

Here is an animation-delay detail almost nobody knows that solves a real problem. Imagine two circular waves expanding outward (the "radar" or "ripple" effect). You want them offset by half a cycle, but you also want the second wave already running from the very first frame, not waiting out its initial delay.

Positive delay fails: for the first half second the second wave sits static. The solution is a negative delay:

.ripple::before,
.ripple::after {
  content: "";
  position: absolute;
  inset: 0;
  border: 4px solid #7c3aed;
  border-radius: 50%;
  animation: loader-ripple 1.5s ease-out infinite;
}
.ripple::after {
  animation-delay: -0.75s;
}

@keyframes loader-ripple {
  from { transform: scale(0); opacity: 1; }
  to { transform: scale(1); opacity: 0; }
}

A negative delay means "start the animation as if it had already been running for X seconds": the browser jumps straight to that point on the timeline. It is the difference between an animation that starts limping and one that seems to have always been there.

Pseudo-elements keep your HTML clean

Notice the ripple uses ::before and ::after: two waves from a single div. For small loaders this matters: fewer DOM nodes, clean markup (<div class="ripple"></div>) and self-contained styles. Pseudo-elements are fully rendered children, capable of receiving their own animations, absolute positioning and borders.

Namespaced keyframe names

When you copy a spinner into your project, its keyframes coexist with everyone else's. A keyframe named spin will collide with any other spin. The safe practice is prefixing them per component: loader-rotation, loader-bounce, loader-pulse. It costs nothing and avoids the classic bug of "one mysteriously broken spinner on another page".

Performance and accessibility

Two notes separating a professional loader from one copied off a tutorial:

Performance: every example above animates only transform and opacity, the two properties browsers composite on GPU without recalculating layout or paint. A spinner animating width, height or margin triggers reflow on every frame.

Accessibility: a purely visual spinner is invisible to screen readers. Add semantic context:

<div class="loader" role="status" aria-live="polite">
  <span class="sr-only">Loading...</span>
</div>

Also respect prefers-reduced-motion: within that media query you can replace the spin with an opacity pulse or plain text.

Generate all six types configured

Choosing type, color, size and duration, seeing it live and copying HTML + CSS together is exactly what our CSS Loader Generator does: ring, dual ring, dots, bars, pulse and ripple, with self-contained code ready to paste into your project.

FAQ

Why not use a GIF or Lottie? A GIF is heavy, pixelates and cannot change color dynamically. Lottie is excellent for complex animations but requires a library and extra bundle size. For indicating load state, CSS is lighter, sharper and customizable at runtime.

How long should each cycle be? Between 0.6 and 1.2 seconds is the usual range. Below 0.5s it creates anxiety; above 2s it looks like something froze.

Can JavaScript pause a loader? Yes: element.getAnimations() returns active animations and you can call .pause() or .play() on them without touching the CSS.


Generate your spinner with live preview using the CSS Loader Generator, free and right in your browser.

Try it without code

CSS Loader Generator

Animated spinners in pure CSS.

Open CSS Loader Generator

Built by

Miguel Ángel Colorado Marin (MACM)

Full-Stack Developer · Guadalajara, España

I develop web apps, digital tools and full projects — from design to deployment.

Contact me