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. Witheasethe 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:transformcomposites on GPU without triggering reflow. Animatingborder-colororwidthforces 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.