Every loading animation explained — from spinner to shader.
The loading animation is the most-built and least-considered UI primitive on the web. Every framework ships with one. Most are bad. Some are iconic. A few are doing things you didn't know CSS could do. This is the complete reference: classics done right, the iconic branded loaders dissected, SVG path tricks, conic gradients with @property, Houdini paint worklets, View Transitions, skeleton screens, scroll-driven loading, and the experimental patterns that nobody is using yet. Every section has working code. None of it depends on a framework.
01Why the loader matters more than you think
The loader is a tiny piece of UI doing a heavy psychological job. It tells the user the system is alive when nothing else can. It absorbs anxiety during the gap between action and result. Done well, it makes a 3-second wait feel like 1. Done badly, it makes 800ms feel like an outage.
The numbers from interaction research are remarkably stable across forty years:
- Under 100ms — feels instant. No loader needed. Adding one actually makes the interaction feel slower because the loader has a perceptual cost.
- 100ms to 1 second — the user notices the delay but stays in flow. A subtle loader (spinner, pulse) is the right call.
- 1 to 10 seconds — attention starts to drift. The loader should give a sense of progress (determinate bar, percentage, or step indication).
- Over 10 seconds — the user mentally checks out. You need a progress estimate AND an option to do something else (background it, get notified, cancel).
The single biggest loader mistake: showing the same indeterminate spinner for every wait length. A 200ms spinner and a 30-second spinner have nothing in common except the shape. Build different loaders for different wait classes.
02The taxonomy — five dimensions
Every loader sits on five axes. Picking the right loader starts with answering these:
- Determinate vs indeterminate. Do you know how long this will take? If yes, show progress. If no, show motion.
- Blocking vs non-blocking. Does the user need to wait, or is this background work? Background work goes in a corner, not center stage.
- Foreground vs inline. Replacing the content area? Sitting next to a button? Each calls for a different scale and intensity.
- Generic vs branded. A native-feel platform spinner, or something that reads as your product? The latter is harder; the former is invisible (in a good way).
- Skeleton vs spinner. If you can show the shape of the content arriving, skeleton beats spinner every time. If you can't, spinner wins because skeleton-of-nothing looks broken.
03The classics, done right
The patterns everyone knows. The default implementations are usually wrong. Here's how the best apps actually build them.
// 3.1 — The Apple 12-spoke indicator
The most-copied loader in computing history. Twelve radial lines, each fading sequentially. Originated in Mac OS 8 (1997), still the default on iOS today. Why it works: the rotation is implied by the fade sequence, not by transform. That makes it smooth at any framerate and unambiguous about direction.
.spinner { width: 36px; height: 36px; position: relative; }
.spinner i {
position: absolute;
left: 50%; top: 50%;
width: 3px; height: 11px;
margin: -16px 0 0 -1.5px;
background: currentColor;
border-radius: 2px;
transform-origin: 50% 16px;
opacity: 0.1;
animation: fade 1s linear infinite;
}
/* 12 children, each rotated 30° and offset by 1/12 of the duration */
.spinner i:nth-child(1) { transform: rotate(0deg); animation-delay: -0.0833s; }
.spinner i:nth-child(2) { transform: rotate(30deg); animation-delay: -0.1667s; }
/* ... and so on for nth-child(3) through (12) */
@keyframes fade {
0%, 100% { opacity: 0.1; }
8.33% { opacity: 1; }
}
The math: 12 spokes ÷ 1 second cycle = 1/12 second per spoke = 0.0833s. The animation peaks at 8.33% of the cycle, which keeps each spoke lit briefly while the rest are fading. Setting background: currentColor means the spinner inherits color from the parent — you can drop it on any background and it tones to match.
// 3.2 — Three dots (the chat indicator)
The "typing..." pattern. Three dots bouncing in sequence. Used in Messages, WhatsApp, every modern chat. The trick is the stagger interval — too tight and it reads as a single blob; too wide and it loses cohesion. Around 150ms between dots is the sweet spot.
.dots { display: flex; gap: 5px; }
.dots i {
width: 7px; height: 7px;
background: currentColor;
border-radius: 50%;
animation: bob 1.2s ease-in-out infinite;
}
.dots i:nth-child(2) { animation-delay: 0.15s; }
.dots i:nth-child(3) { animation-delay: 0.3s; }
@keyframes bob {
0%, 100% { transform: translateY(0); opacity: 0.3; }
50% { transform: translateY(-6px); opacity: 1; }
}
// 3.3 — Indeterminate progress bar (Material)
Two segments sliding right at different rates. Material Design introduced this in 2014; it's now the default for the <progress> element on Chrome and Firefox. The genius: it never repeats exactly — segments overlap or separate slightly each cycle — so it doesn't feel mechanical.
.matbar {
width: 100%; height: 3px;
background: rgba(139,92,246,0.15);
border-radius: 999px;
overflow: hidden;
position: relative;
}
.matbar::before,
.matbar::after {
content: '';
position: absolute;
height: 100%;
background: currentColor;
border-radius: 999px;
}
.matbar::before { animation: mat-1 2.1s cubic-bezier(0.65,0.81,0.73,0.4) infinite; }
.matbar::after { animation: mat-2 2.1s cubic-bezier(0.16,0.84,0.44,1) infinite; animation-delay: 1.15s; }
@keyframes mat-1 { 0% { left: -35%; right: 100%; } 60%,100% { left: 100%; right: -90%; } }
@keyframes mat-2 { 0% { left: -200%; right: 100%; } 60%,100% { left: 107%; right: -8%; } }
<progress> element styles with accent-color in modern browsers. For determinate progress, this is one tag and zero JavaScript: <progress value="40" max="100"></progress>. Style with progress { accent-color: var(--pink); }. It's accessible, native, and almost no one uses it.// 3.4 — Pulse and ripple
Pulse is one element scaling. Ripple is one element generating expanding rings outward. Pulse says "alive"; ripple says "active reaching out." Both are subtle, both work as button-adjacent indicators where a spinner would feel heavy.
/* Pulse — single circle */
.pulse {
width: 16px; height: 16px;
background: currentColor;
border-radius: 50%;
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%,100% { transform: scale(0.6); opacity: 0.4; }
50% { transform: scale(1.4); opacity: 1; }
}
/* Ripple — staggered rings */
.ripple { position: relative; width: 40px; height: 40px; }
.ripple i {
position: absolute;
inset: 0;
border: 2px solid currentColor;
border-radius: 50%;
animation: ripple-out 1.4s cubic-bezier(0.4,0,0.2,1) infinite;
}
.ripple i:nth-child(2) { animation-delay: 0.5s; }
@keyframes ripple-out {
0% { transform: scale(0.3); opacity: 1; }
100% { transform: scale(1.4); opacity: 0; }
}
// 3.5 — Equalizer bars
Five vertical bars scaling independently. Originally a music-player visualizer; now seen on voice messages, podcast players, AI listening indicators (Siri, Alexa). The trick: each bar has a different animation-delay AND a different starting height. Identical bars look mechanical; varied heights look organic.
04Iconic branded loaders, dissected
Loaders that became visual signatures for their products. Worth understanding even if you don't copy them — they teach the design principles that separate generic loaders from ones that read as a specific brand.
- Apple's 12-spoke (covered above) — the most-copied loader in computing.
- Google's Material arc — a single arc rotating while changing color through red, yellow, green, blue. Uses two animations: one for rotation, one for stroke-dashoffset to change the arc's apparent length. Built with SVG so the colors transition smoothly.
- Google's AI bloom — the conic-gradient halo (Gemini, AI Studio). A radial-gradient masked into a circle, with the conic angle animating via
@property. Reads as "thinking" rather than "loading," which fits AI better than a spinner does. - Vercel's triangle — the logo itself does the loading. Three lines drawing in via stroke-dashoffset, then erasing in reverse. The product becomes its own loader. This is the highest-effort, highest-payoff branded loader pattern.
- Linear's smooth ring — a thin arc rotating at a perfect 60fps via
@property --angleanimating a conic-gradient. The compositor handles the entire animation; no layout, no paint. - Stripe's payment animation — three horizontal lines (the Stripe logo) sliding in sequence, with a brief settling motion at the end. Loading-into-success is one continuous animation rather than a hard cut.
- Discord's playful bounce — three squares bouncing with a slight stretch on the apex. The stretch (scaleY non-uniform on each bar) is what gives it personality.
- Notion's subtle nothing — Notion famously uses almost no loaders. Instead, content stays visible during loading with subtle opacity reduction, and the new content fades in. This is the right call when your loads are fast and your content can be cached optimistically.
The pattern across all of these: the loader is an extension of the brand, not a generic primitive bolted on. If your product has a strong visual identity, the loader is one of the highest-leverage places to express it — it's seen by every user, every session, before they see anything else.
05SVG path loaders
SVG opens an entire class of loaders that CSS alone can't produce. The trick that powers most of them is stroke-dasharray + stroke-dashoffset — by treating a stroke as a sequence of dashes and shifting the offset, you can make any path appear to draw itself.
<svg viewBox="0 0 50 50" width="36">
<circle cx="25" cy="25" r="18"/>
</svg>
svg circle {
fill: none;
stroke: currentColor;
stroke-width: 3;
stroke-linecap: round;
transform-origin: center;
animation:
rotate 1.5s linear infinite,
dash 1.5s ease-in-out infinite;
}
@keyframes rotate { to { transform: rotate(360deg); } }
@keyframes dash {
0% { stroke-dasharray: 1 150; stroke-dashoffset: 0; }
50% { stroke-dasharray: 90 150; stroke-dashoffset: -35; }
100% { stroke-dasharray: 90 150; stroke-dashoffset: -124; }
}
Two animations running together: rotation (constant) and arc-length (grow then shrink). The combination produces the "snake chasing its tail" loader that Material made famous. Replace the circle with any path — your logo, an infinity loop, a hand-drawn curve — and the technique adapts.
Other path-based loaders worth knowing:
- Infinity loop: draw a figure-8 path, animate stroke-dashoffset to make the line travel around it. Calmer than a spinner; reads as "ongoing" rather than "waiting."
- Logo self-drawing: calculate the path length with JS (
path.getTotalLength()), set dasharray and dashoffset to that length, then animate to 0. The logo draws itself stroke-by-stroke. Used effectively in product onboarding flows. - Path morphing: using SMIL (
<animate>) or JS, animate thedattribute between two shapes. Heavy, but visually distinctive — useful for the moment of transition between loading and loaded state.
06Conic gradients + @property — the modern primitive
The @property custom property type, finally supported in every modern browser, unlocks animating things CSS previously couldn't animate — including the from angle of a conic-gradient. This is the technique behind Linear's loader, Google's AI bloom, and almost every "premium-feeling" 2026 loader.
@property --ring-a {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
.ring {
width: 38px; height: 38px;
border-radius: 50%;
background: conic-gradient(
from var(--ring-a),
transparent 0 65%,
var(--pink) 80%,
var(--violet)
);
/* Cut the center out to make it a ring, not a disc */
mask: radial-gradient(circle, transparent 12px, #000 13px);
animation: spin 0.9s linear infinite;
}
@keyframes spin { to { --ring-a: 360deg; } }
What makes this superior to a border-spin: the entire animation is a single GPU-composited paint of a gradient. No layout, no per-frame stroke calculation, no SVG rendering. Sustains 120fps on any device with a discrete GPU. The mask creates the ring shape without needing two stacked elements.
Three variations on the same technique that are worth keeping in your back pocket:
- Multi-color conic: add more color stops for a rainbow chase. Combine with
animation-direction: alternatefor a non-repeating feel. - Conic + radial halo: stack two backgrounds — the conic ring on top, a radial-gradient blur behind it — for the "glowing" Linear/Stripe look.
- Conic with filter: blur(): apply a slight blur to the entire element for the soft, ambient feel of Google's AI bloom. Costs a paint per frame but looks unmistakable.
07SVG filters — the goo effect and beyond
Take three plain circles bouncing in a row. Apply filter: url(#goo) where the filter is a Gaussian blur followed by a contrast-stretching color matrix. The result: the circles appear to merge and separate like liquid blobs. This effect is impossible in CSS alone but trivial with SVG filters.
<!-- Declare the filter once, anywhere in your document -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="goo">
<feGaussianBlur in="SourceGraphic" stdDeviation="6"/>
<feColorMatrix values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -11"/>
</filter>
</defs>
</svg>
.goo { position: relative; filter: url(#goo); }
.goo i {
position: absolute;
width: 20px; height: 20px;
background: var(--pink);
border-radius: 50%;
animation: bob 2s ease-in-out infinite;
}
.goo i:nth-child(2) { animation-delay: 0.3s; left: 35px; }
.goo i:nth-child(3) { animation-delay: 0.6s; left: 70px; }
How it works: the Gaussian blur smears the circles into soft halos. The color matrix then aggressively pushes the alpha channel — the last row, 0 0 0 22 -11, means "multiply alpha by 22, then subtract 11." This collapses the soft gradients back into hard-edged shapes, but the blurred regions where two circles overlap become solid. Result: blobs merge.
Other filter-based loaders worth knowing:
- Turbulence + displacement: use
feTurbulencefor noise +feDisplacementMapto warp another element. Animate the turbulence base frequency for swirling, liquid distortion. Used in some game UIs. - Drop-shadow pulse: animate
filter: drop-shadow()on an SVG icon for a glowing pulse without affecting layout. The shadow follows the alpha shape rather than the bounding box, so it works on irregular icons. - backdrop-filter pulse: for a "ghost" loader behind content, animate
backdrop-filter: blur()on a fixed overlay. The content behind appears to soften and sharpen. Surprisingly effective for full-page loading transitions.
08Skeleton screens — done right, not done wrong
Skeleton screens are the most-misused loader pattern. The intent: show the shape of incoming content so the layout is familiar before the data arrives. Done well, they make the load feel near-instant. Done badly, they look like cheap placeholder boxes that distract from the experience.
.skeleton-row {
height: 9px;
background: linear-gradient(
90deg,
rgba(139,92,246,0.10) 0%,
rgba(255,64,129,0.22) 50%,
rgba(139,92,246,0.10) 100%
);
background-size: 200% 100%;
border-radius: 4px;
animation: shimmer 1.6s ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
Three rules for skeletons that don't look cheap:
- Match the real content's dimensions exactly. If your final headline is 28px tall, the skeleton bar is 28px tall. If your card has rounded corners, so does the skeleton. The skeleton-to-content transition should not visibly resize anything.
- Vary the widths. Real content has irregular widths. Three identical-length skeleton bars look like a placeholder; bars at 100%, 80%, and 60% look like text.
- Subgrid for aligned multi-item layouts. When skeletons are inside a grid (cards, table rows), use
display: subgridso each skeleton's parts align to the same columns as the real content's parts. This makes the transition seamless.
When NOT to use skeletons:
- Loads under 400ms. The skeleton flashes in and out and looks like a glitch. Use nothing, or a delayed spinner that only appears after 300ms.
- Tiny UI surfaces. A skeleton on a button or small badge looks broken. Spinners work better for small surfaces.
- Highly variable content. If you don't know the shape of what's loading — could be 3 items, could be 30 — a skeleton can't represent it honestly. Use a spinner.
09The cutting edge — View Transitions, scroll-driven, Houdini
Three browser APIs that have been quietly landing and unlock loader patterns nothing else can do. None of these are widely used yet. All three are production-ready in 2026 with appropriate fallbacks.
// 9.1 — View Transitions for skeleton → content
The View Transitions API (a Chrome/Edge feature for several years, now in Safari and Firefox) lets the browser automatically morph between two DOM states. Pair this with skeleton screens and you get an animation that's impossible to write by hand: each skeleton bar smoothly morphs into the corresponding real content as the data arrives.
/* Mark skeleton and real elements with the same view-transition-name */
.skeleton-title,
.real-title { view-transition-name: var(--name); }
/* When data arrives, swap with a transition */
async function loadAndSwap() {
const data = await fetch('/api/article').then(r => r.json());
if (!document.startViewTransition) {
// Fallback: just swap
renderArticle(data);
return;
}
document.startViewTransition(() => renderArticle(data));
}
The browser handles the morph automatically. Each view-transition-name matches between old and new DOM; the browser captures their old positions, runs your DOM update, then animates from old to new. The result is the highest-quality skeleton → content transition possible — far better than crossfading.
// 9.2 — Scroll-driven loading indicators
The CSS animation-timeline: scroll() property ties an animation's progress to scroll position rather than wall-clock time. For loaders, this enables patterns like: a top progress bar that fills as the user scrolls through long content (the "reading progress" you see on Medium and major publications) — implemented in pure CSS with no JavaScript.
.progress {
position: fixed;
top: 0; left: 0;
height: 3px;
width: 100%;
background: var(--pink);
transform-origin: left;
animation: grow linear;
animation-timeline: scroll(root);
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
Five lines of CSS replace a JS reading-progress library. The browser handles the scroll-listening and the animation interpolation natively, off the main thread.
// 9.3 — Houdini paint worklets
The CSS Houdini Paint API lets you write a custom paint function in JavaScript that the browser calls to fill a background or border-image. Inside the paint function you get a Canvas-like context and access to the element's CSS custom properties. The result: you can build loaders that aren't expressible in CSS at all — procedural noise, particle systems, Perlin flows — and use them with background: paint(my-loader).
Browser support: Chrome and Edge ship Houdini paint worklets natively; Firefox and Safari are behind on this specific API. For now, use it as a progressive enhancement — provide a CSS fallback that activates when the worklet isn't supported.
10Experimental and almost-nobody-uses-these
Patterns that exist but rarely appear in production. Worth knowing because the right context makes them feel genuinely distinctive.
// 10.1 — ASCII / terminal loaders
A rotating single character — | / - \ — using the content property and CSS steps() timing. Reads as authentically retro. Perfect for CLI dashboards, terminal-themed sites, or anywhere a "real" loader would feel out of place.
.ascii::before {
content: '|';
animation: cycle 0.4s steps(4) infinite;
}
@keyframes cycle {
0% { content: '|'; }
25% { content: '/'; }
50% { content: '-'; }
75% { content: '\\'; }
}
Animating content on pseudo-elements is a relatively new CSS capability (2023+). It opens a category of typographic loaders that previously required JavaScript: dots cycling (. → .. → ...), counting numbers, rotating words ("Loading" → "Thinking" → "Almost there"). All pure CSS, all small, all distinctive in the right context.
// 10.2 — Dual concentric rings
Two rings rotating in opposite directions at different speeds. The visual rhythm — fast outer, slow inner, opposite directions — feels mechanical in a controlled way. Used in some military/scientific UIs and in apps that want a "technical" register.
// 10.3 — CRT scanline / glitch loaders
Horizontal lines sweeping vertically across the loader area, with subtle RGB channel splitting via text-shadow on red/cyan offsets. Reads as "old monitor" or "retro arcade." Pairs naturally with monospace typography and saturated colors. Excellent for game UIs and brutalist designs.
// 10.4 — Particle / canvas-based loaders
For loaders where CSS isn't enough — orbiting particles, gravitational flows, fluid simulations — drop down to Canvas 2D or WebGL. The cost is JavaScript on the main thread (Canvas 2D) or a shader compilation (WebGL). The benefit is effects that look like nothing else on the web. Used carefully — long-form load screens, premium product launches — they read as a tier of polish above pure-CSS loaders.
// 10.5 — The "no loader" loader
The most underrated pattern: don't show a loader at all. Use optimistic UI — render the predicted state immediately, then reconcile when the real data arrives. Used to extreme effect in Linear, Notion, and modern collaborative apps. The work that goes into making this seamless is enormous (you need conflict resolution, rollback paths, and rock-solid network handling), but the perceived performance is unbeatable.
11Accessibility — the rules nobody follows
Every loader you ship needs to consider three things. Most loaders consider none of them.
1. Reduced motion. Users can request reduced motion at the OS level. Respect it. The CSS query is @media (prefers-reduced-motion: reduce). For most loaders, the right behavior is to disable the animation entirely and show a static representation (a static spinner with text, or a non-animating progress bar). Don't show nothing — that breaks the "this is loading" affordance.
@media (prefers-reduced-motion: reduce) {
.spinner,
.dots i,
.matbar::before,
.matbar::after {
animation: none !important;
}
/* Replace with a static "Loading..." text label */
.spinner::after { content: 'Loading…'; }
}
2. Screen reader announcement. A spinning visual element is invisible to screen readers. Wrap the loader in a container with role="status" and aria-live="polite", with a text label that describes what's happening. When loading completes, update the label.
<div role="status" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
<span class="sr-only">Loading articles</span>
</div>
3. Don't trap focus, don't break keyboard navigation. Full-page loading overlays must not capture keyboard focus indefinitely. If the user was tabbing through a form when a save kicked off, they should still be able to tab through it (or be returned to their position when the load completes). Default modal-loader libraries almost always get this wrong.
12Performance — the rules of smooth animation
Loaders animate forever. Even tiny performance costs compound — a loader that costs 2ms per frame consumes 720ms per minute of attention. The rules:
- Animate only
transformandopacitywhere possible. These are the only two properties browsers can animate on the GPU compositor without touching layout or paint. Animatingwidth,top,margin, or anything that triggers reflow will hit the main thread on every frame. - Conic gradients and SVG paths are paint-bound, not composite-bound. They look smooth at 60fps on modern hardware but cost more on weak devices. Test on a budget phone before shipping.
will-changeis not a magic word. Apply it sparingly to elements that actually need compositor promotion. Applied liberally, it bloats GPU memory.- Pause animations off-screen. Loaders inside elements that have scrolled out of view should pause. Use
IntersectionObserverto toggleanimation-play-state. - Delay the loader if the load is fast. Don't show any loader for the first 200-300ms. If the load completes by then, the user never sees a flash. If it doesn't, the loader appears with intent.
.spinner {
animation: spin 1s linear infinite,
fade-in 200ms ease-out 300ms both;
/* fade-in starts 300ms after element appears, finishes by 500ms */
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
13The decision matrix
Picking the right loader, in plain language:
- Wait < 200ms: nothing. A flash of any loader makes things feel slower.
- Wait 200ms – 1s, single component: small spinner (Apple-style or three dots), delayed by 200ms. Stays in-place; doesn't shift layout.
- Wait 200ms – 1s, full page: top-of-page indeterminate bar (Material). Doesn't block content; reads as "in progress."
- Wait 1s – 5s, content-shaped: skeleton screen matching the incoming layout exactly. Real-content morph if you have View Transitions.
- Wait 1s – 5s, content-shapeless: spinner with progress text, plus a meaningful description of what's loading ("Loading orders" beats "Loading").
- Wait 5s – 30s: determinate progress bar with percentage. If you can't measure progress, fake it with a slow-asymptotic curve and switch to determinate when you can.
- Wait > 30s: progress + estimated time + option to be notified when done. Don't trap the user.
- Branded product moment: custom loader that reads as your brand (logo-draw, color-locked palette, distinct motion signature). Worth the investment if it's seen often.
∞The compound
The loader is one of the few UI elements every user sees in every session. Improving it pays back constantly. A loader that feels right doesn't draw attention to itself — it just makes the wait feel shorter than it is. A loader that feels wrong becomes a tax on every interaction.
The premium feeling of the best 2026 apps — Linear, Vercel, Stripe, Notion — comes in large part from loaders that match the rest of the work: considered, branded, performant, and accessible. None of this requires a heavy library. The patterns above are all small, all pure CSS or SVG or a few lines of vanilla JS, and all production-ready today.
Pick the right pattern for the wait length. Match the loader's character to the rest of the product. Respect reduced motion. Delay the small ones. Skeleton the content-shaped ones. Brand the long ones. That's the entire discipline.