CSS Spinner Examples: Five Copyable Loaders

- Five CSS spinner examples you can copy
- Use one accessible loading wrapper
- Example 1: a dependable ring spinner
- Example 2: a sliding line loader
- Example 3: three staggered dots
- Example 4: a quiet pulse
- Example 5: a measured progress bar
- Why these examples animate transform and opacity
- Add reduced-motion behavior once
- How to choose without overthinking it
- Check the whole loading lifecycle
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.