A loading state is the one piece of UI almost every product ships without much thought, and the one users notice the moment it’s wrong. A spinner that never resolves reads as broken. A skeleton that doesn’t match the content it precedes causes a visible jump the instant real data lands. A button that goes quiet with no feedback gets clicked three more times. None of this is exotic — it’s a small, well-understood set of patterns, and picking the right one for the right situation is most of the battle. This post is a gallery of eleven distinct loading states, each implemented in plain HTML and CSS with a touch of vanilla JavaScript, so you can see exactly how each one is built and when it earns its place over the others.
Every demo below is live and interactive right here in the article — click the trigger button on each one and watch the actual pattern play out over a couple of seconds, the same way it would against a real request. Every embed also has a Fork & Edit button that opens the exact code, already running, in My Code — webdevpuneet’s free in-browser editor. Change a duration, swap a color, delete a transition — that’s a faster way to understand why a loading state feels right than any explanation.
New to building these from scratch? The CSS Playground is a free, click-based, in-browser course that covers the animations, transforms and transitions every pattern below is built from, one guided step at a time.
Why the right loading state matters more than it looks like it should
Perceived performance is a different thing from actual performance, and loading states are almost entirely about the former. A request that takes 1.5 seconds with a well-designed loading state can feel faster than one that takes 900 milliseconds with none — because a blank screen or a frozen button reads as “did this work?” long before it reads as “this is slow.” The patterns below split into a few families: pure motion (spinners, progress bars) that reassures the user something is happening; structural placeholders (skeletons, shimmer) that reduce the jump when content finally arrives; and state-driven patterns (button loading, optimistic UI) that keep a specific control honest about what it’s doing. Knowing which family a given moment in your UI belongs to is what determines which demo below to reach for.
1. Classic Rotating Spinner
Click “Simulate load” to trigger a fake 1.9 second request and watch the ring spin, then resolve into a checkmark.
How it works:
.spinner {
width: 44px; height: 44px;
border: 4px solid #e3e5ea;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
A spinner is just a circle with one differently-colored border edge, rotated continuously — the “loading” read comes entirely from motion, not from any actual information about progress. That’s both its strength and its limit: because it conveys nothing about how much is left, it’s the right tool exactly when there’s nothing to convey — a request whose duration you can’t estimate ahead of time, like a network round trip. border-top-color differing from the other three sides is what gives the eye a fixed point to track as it spins; without that contrast the animation reads as a static ring. Keeping the animation on transform rather than something like rotating background positions is also what keeps it running smoothly on the GPU instead of forcing layout recalculation on every frame, which matters once several spinners are on screen at once.
Best for: short, unpredictable waits where you have no useful percentage to show — a button click, a small inline fetch, a modal opening. Tip: never let a spinner spin indefinitely with no timeout or fallback message; past 8-10 seconds it stops reading as “loading” and starts reading as “broken.”
2. Skeleton Screen Placeholder
Click “Simulate load” — the gray placeholder blocks pulse in the exact shape of the final card, then swap for the real content with no layout shift.
How it works:
.skel {
background: #e7e9ee;
border-radius: 6px;
animation: pulse 1.1s ease-in-out infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
A skeleton screen replaces the spinner’s “something is happening, somewhere” with “here is the shape of what’s coming.” Each placeholder block is sized to match the real element it stands in for — an avatar-sized circle, a title-height bar, a body-width bar — so the layout the user sees during loading is the same layout they’ll see once content lands. That’s the entire point: nothing shifts, resizes, or jumps when the swap happens, because the skeleton already occupies the correct footprint. The gentle opacity pulse exists purely to distinguish “this is a loading placeholder” from “this is a plain gray box that might be a bug” — without it, a skeleton on a slow connection can look like broken CSS rather than a deliberate loading state.
Best for: content-heavy cards, feeds, and profile-style layouts where you know the final shape ahead of time. Tip: the placeholder’s dimensions have to genuinely match the real content’s — a skeleton line that’s narrower or shorter than the text it precedes still causes the exact layout jump skeletons exist to prevent.
3. Shimmer Sweep
Click “Simulate load” — a light band sweeps left to right across the placeholder blocks, a step up from a flat pulsing gray.
How it works:
.shimmer {
background: linear-gradient(90deg, #e7e9ee 25%, #f4f5f8 37%, #e7e9ee 63%);
background-size: 400% 100%;
animation: shimmer 1.3s ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
Shimmer is a skeleton with a moving highlight instead of a pulsing opacity — the gradient is four times wider than the element itself, and animating background-position slides that wide gradient across the visible box, so a lighter band appears to travel through it. The reason this reads as more “alive” than the plain pulse in demo #2 is directional motion: a pulse fades the whole shape in and out uniformly, while a sweep gives the eye something to follow left to right, which is closer to how reading and scanning actually work. It’s a pure CSS effect — no JavaScript needed to animate it, only to swap the skeleton block for real content once the fake request resolves. Popularized by content feeds like LinkedIn and Facebook, it’s become the default “premium” skeleton treatment.
Best for: any skeleton screen where a slightly more polished, branded loading feel is worth the extra few lines of CSS over a plain pulse. Tip: keep the gradient’s light band subtle — a shimmer that’s too bright or too fast reads as distracting rather than reassuring, especially when several skeleton cards are shimmering on screen at once.
4. Determinate Progress Bar
Click “Start upload” — the bar fills to an actual known percentage instead of just animating forever, so you always know how much is left.
How it works:
var pct = 0;
var timer = setInterval(function () {
pct += 4 + Math.random() * 14; // real code: bytes uploaded so far
fill.style.width = Math.min(pct, 100) + '%';
pctLabel.textContent = Math.round(Math.min(pct, 100)) + '%';
if (pct >= 100) clearInterval(timer);
}, 180);
A determinate progress bar only works when you genuinely know a percentage — bytes uploaded out of total bytes, files processed out of total files, steps completed out of total steps. The CSS itself is trivial (a track div and a fill div whose width is set from JavaScript with a short transition so each jump animates smoothly instead of snapping); the actual design decision is what number drives it. In the demo, the increments are deliberately uneven — real uploads don’t move at a perfectly constant rate, and a bar that fills in exactly equal steps every frame subconsciously reads as fake, the same way a countdown timer that’s too round feels staged. Giving users a real percentage lets them make a decision a spinner never can — whether to wait it out or come back later.
Best for: file uploads, multi-step imports, and any process where a genuine percentage or step count exists. Tip: never fake a determinate bar with an arbitrary timer when you don’t actually have progress data — an indeterminate bar (next demo) is the honest choice, and a fake percentage that stalls at 94% erodes trust faster than no percentage at all.
5. Indeterminate Progress Bar
Click “Simulate load” — a band slides back and forth with no percentage at all, because the actual duration isn’t known ahead of time.
How it works:
.band {
position: absolute; top: 0; left: 0; height: 100%;
width: 35%; border-radius: 999px; background: #6366f1;
animation: slide 1.1s ease-in-out infinite;
}
@keyframes slide {
0% { left: -35%; }
100% { left: 100%; }
}
This is visually similar to demo #4 — same track, same fill color — but structurally closer to the spinner in demo #1: a fixed-width band moves back and forth with no relationship to actual progress, because none exists yet to report. It’s the correct choice specifically for requests where a percentage would be a lie — a database query, a search, a server-side job whose duration genuinely varies run to run. The bar shape communicates “this kind of operation” (a linear process, not a point-in-time action) even without a number attached to it, which is a useful middle ground between a bare spinner and a fully numeric progress bar. Swapping between demo #4 and #5’s markup is literally a one-line change — whether width is driven by real data or an animation — which makes it easy to pick the honest one for whatever data you actually have.
Best for: operations whose duration can’t be estimated up front but are still linear, ongoing processes rather than a single instant — page transitions, search queries, background jobs. Tip: pair it with a status message that changes over time (“Connecting…” then “Processing…”) if the wait tends to run long — it gives the same reassurance a percentage would, without inventing a number you don’t have.
6. Button Loading State
Click “Save changes” — the button itself becomes the loading indicator: disabled, a spinner in place of its icon, then a confirmed state.
How it works:
saveBtn.disabled = true;
saveBtn.className = 'saveBtn saving';
icon.innerHTML = '<span class="spinnerSm"></span>';
label.textContent = 'Saving…';
setTimeout(function () {
saveBtn.className = 'saveBtn done';
icon.textContent = '✓';
label.textContent = 'Saved';
saveBtn.disabled = false;
}, 1600);
Rather than showing a separate spinner elsewhere on the page, the button that triggered the action becomes the loading indicator directly — its icon swaps for a small inline spinner, its label changes to a present-tense verb, and it’s disabled so a second click can’t fire a duplicate request. This solves a specific, common bug: a save button with no visual feedback gets clicked again (and again) by a user who isn’t sure the first click registered, which is exactly how double form submissions happen. Setting disabled is what actually prevents that; the spinner and label change are what make the disabled state legible instead of just looking unresponsive. Fixing the button’s min-width also matters here — swapping “Save changes” for “Saving…” and then “Saved” changes the text length each time, and without a minimum width the button itself visibly resizes with every state change.
Best for: any form submission, save action, or single-click operation where a duplicate click would cause a real problem. Tip: always disable the button itself, not just show a spinner next to it — a spinner with a still-clickable button underneath is a common source of duplicate-submit bugs in production.
7. Optimistic UI Update
Click the heart a few times — it updates instantly, before any “server” reply. About 1 in 4 clicks simulates a failed request and rolls back, so you can see both outcomes.
How it works:
var wasLiked = liked, wasCount = count;
liked = !liked; // update the UI FIRST
count = liked ? count + 1 : count - 1;
render();
fakeRequest().then(function (ok) {
if (!ok) { liked = wasLiked; count = wasCount; render(); } // roll back
});
Optimistic UI inverts the usual order of operations: instead of showing a loading state and waiting for a response before updating anything, it updates the interface immediately, assuming the request will succeed, and quietly rolls back only if it doesn’t. For fast, high-frequency, low-stakes actions — likes, follows, toggles, reactions — this removes the loading state from the equation almost entirely, which is why these interactions feel instantaneous on most modern social apps. The trade-off is real, though: the demo deliberately fails about a quarter of the time to make the rollback visible, snapping the heart and count back to exactly what they were before, with a short message explaining why. That rollback path isn’t optional — an optimistic update with no failure handling just silently lies to the user on the requests that don’t go through.
Best for: low-stakes, easily reversible actions where the vast majority of requests succeed — likes, favorites, toggles, drag-and-drop reordering. Tip: never use optimistic UI for anything hard to undo or high-consequence, like a payment or a delete — the brief window where the UI shows something that hasn’t actually happened yet is the wrong trade for actions where being wrong is expensive.
8. Skeleton-to-Content Crossfade
Click “Simulate load” — skeleton and real content are stacked in the same box and crossfade, so the card’s height never jumps.
How it works:
.card { position: relative; height: 168px; } /* fixed height, both layers fit it */
.layer { position: absolute; inset: 0; transition: opacity 0.45s ease; }
.skelLayer { opacity: 1; }
.realLayer { opacity: 0; }
.card.loaded .skelLayer { opacity: 0; }
.card.loaded .realLayer { opacity: 1; }
Demo #2 swaps the skeleton’s markup for real content directly, which is simple but produces an instant, un-animated cut the moment the data lands. This pattern keeps both versions in the DOM at once, stacked exactly on top of each other with position: absolute; inset: 0 inside a card whose own height is fixed, and animates only their opacity. Because the card’s height never depends on which layer is visible, adding a toggled loaded class produces a smooth 450ms crossfade instead of a jump cut, with zero risk of the classic skeleton failure mode — a placeholder shorter or taller than its real content causing a jarring resize. The cost is a genuinely fixed container height, which only works when you know the final content’s dimensions ahead of time (an image at a known aspect ratio, a card with a capped line count).
Best for: cards, thumbnails, and any component where you know the exact final dimensions and want the transition itself to feel considered, not abrupt. Tip: this technique breaks down the moment content height is unpredictable — variable-length text, for instance — since a fixed-height container will either clip long content or leave visible gaps under short content.
9. Pulsing Typing Indicator
Click “Send message” — three dots bounce in the reply bubble while a response is “typed,” then the dots are replaced by the actual reply text.
How it works:
.dot {
width: 7px; height: 7px; border-radius: 50%; background: #6b7080;
animation: bounce 1s ease-in-out infinite;
}
.dot:nth-child(2) { animation-delay: 0.15s; }
.dot:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
30% { transform: translateY(-4px); opacity: 1; }
}
Three identical dots share one @keyframes animation, and the only difference between them is animation-delay staggered by 0.15s each — that offset is the entire trick, turning one bounce animation into what reads as a wave moving left to right. It’s a purpose-built variant of the spinner: same “something is happening, duration unknown” message, but shaped and sized specifically to sit inside a chat bubble, which is why it’s become the near-universal convention for “the other person (or the AI) is composing a reply” across messaging products. Keeping it visually distinct from a generic spinner matters here — a spinner inside a chat bubble reads as “loading the conversation,” while three dots specifically read as “someone is actively typing right now,” a subtly different and more accurate message.
Best for: chat interfaces, AI assistant responses, and any “the other party is composing something” moment. Tip: keep the dot count at three — it’s long enough to read as a wave rather than a single blink, and short enough to stay compact inside a narrow chat bubble.
10. Staggered List Reveal
Click “Simulate load” — each row fades and slides in slightly after the one above it, instead of the whole list appearing at once.
How it works:
items.forEach(function (item, i) {
var li = buildRow(item);
li.classList.add('entering'); // opacity: 0, translateY(6px)
list.appendChild(li);
setTimeout(function () {
li.classList.remove('entering');
li.classList.add('entered'); // transitions to opacity: 1, translateY(0)
}, i * 110);
});
Every row is appended to the list already in its hidden “entering” state, and a setTimeout delayed by i * 110 milliseconds — the row’s index times a fixed stagger interval — is what removes that class and lets its own CSS transition run. The visual effect reads as data “arriving” in sequence rather than the list simply snapping into existence, which matters most for longer feeds where an instant swap from skeleton to twenty rows at once can feel more like a jarring pop-in than a load. Because each row only needs to know its own index to compute its delay, this scales to any list length without extra bookkeeping — the loop above works identically whether there are 4 items or 40, just with a longer total reveal time.
Best for: feeds, search results, and notification lists where content arrives as a batch and a single simultaneous pop-in would feel abrupt. Tip: keep the per-row delay under roughly 150ms and cap the total stagger — a list of 30 items staggered at 110ms each takes over 3 seconds to fully render, which stops feeling polished and starts feeling slow.
11. Blur-Up Image Loading
Click “Simulate load” — a soft blurred placeholder (the image’s real colors, just heavily blurred) sharpens into the finished image instead of showing a blank box.
How it works:
.ph {
filter: blur(18px);
transform: scale(1.15); /* hides the blurred edge softening past the box */
transition: filter 0.6s ease, transform 0.6s ease;
}
.ph.sharp { filter: blur(0); transform: scale(1); }
The technique — popularized as “LQIP” (low-quality image placeholder) by sites like Medium — shows a tiny, heavily blurred version of the actual image immediately, then swaps or sharpens it into the full-resolution version once that’s downloaded. The blur is what makes a handful of pixels look intentional rather than broken; a small blurred JPEG (often under 1KB) still carries the image’s real colors and rough composition, so the placeholder is recognizably related to what’s coming instead of a generic gray box. The slight scale(1.15) is a practical detail: blurring an element also visibly softens its edges past the element’s own boundary, and scaling it up just enough hides that soft edge so it doesn’t peek past the container’s border. Real implementations generate the tiny placeholder server-side and swap the <img src> once the full image loads; this demo fakes the swap with a timer and a CSS class toggle to show the transition itself.
Best for: hero images, photo galleries, and any content-heavy page where blank white boxes during image load would be jarring. Tip: generate the blurred placeholder at build time or upload time, not in the browser — the whole point is that the tiny placeholder is available instantly, before the real image has downloaded at all.
Common pitfalls
- A spinner with no timeout or failure state. Past 8-10 seconds a spinner stops reading as “loading” and starts reading as “frozen” — always pair it with a fallback message or retry option for requests that can genuinely fail or hang.
- A skeleton that doesn’t match the final layout’s dimensions. If the placeholder is narrower, shorter, or differently proportioned than the real content, the swap causes exactly the layout jump skeletons exist to prevent — demo #8’s crossfade sidesteps this by fixing the container’s height outright.
- No minimum display time, causing flicker on fast connections. If a request sometimes resolves in 80ms, showing a loading state for that long can flash on and off faster than it’s readable — a short artificial minimum (150-250ms) keeps it from looking like a glitch.
- Missing
aria-liveoraria-busyfor screen reader users. A purely visual loading state is invisible to anyone using a screen reader;aria-busy="true"on the loading container and anaria-live="polite"region for status changes are what carry the same information non-visually. - Disabling a button without visually showing why. A button that goes gray and unresponsive with no spinner or label change reads as broken, not busy — demo #6’s combination of disabled state, spinner, and text change is what keeps a disabled control legible.
- Ignoring reduced-motion preferences. Spinners, shimmer sweeps and bouncing dots run forever, which is exactly what
prefers-reduced-motion: reduceusers have asked to avoid. Wrap the motion in@media (prefers-reduced-motion: no-preference), or swap it for a slow opacity pulse or a plain “Loading…” label when the user prefers less motion.
Frequently asked questions
Should I use a spinner or a skeleton screen?
Reach for a skeleton (demos #2, #3, #8) whenever you know the shape of the content that’s coming — a card, a list, a profile — since it also reduces the layout shift when that content lands. Reach for a spinner (demo #1) or an indeterminate bar (demo #5) when there’s no meaningful shape to preview, like a short action with no structured result, such as a button click or a small inline confirmation.
Is there a minimum amount of time a loading state should show?
Yes, in practice — a loading state that appears and disappears in under roughly 150ms can flicker rather than communicate anything, especially on a fast connection or a cached response. A common fix is tracking the request’s start time and, if it resolves faster than a set minimum, holding the loading state visible for the remainder of that minimum before showing the result.
How do I make loading states accessible to screen reader users?
Mark the loading container with aria-busy="true" while it’s active, and use a live region (aria-live="polite", or role="status") to announce meaningful status changes, like “Loading” and then “Loaded.” Purely decorative motion — like a spinning icon — doesn’t need its own announcement; what matters is that the start and end of the wait are communicated in text, not just conveyed visually.
What’s the actual risk with optimistic UI?
The interface shows a result before the server has confirmed it actually happened, which means every optimistic update needs a genuine rollback path for the failure case — demo #7 shows this directly, snapping back to the exact prior state and surfacing a short message when the simulated request fails. Skipping the rollback path isn’t a shortcut; it’s a bug that only shows up once real requests start failing in production.
Why do some of these demos use CSS animations and others use JavaScript timers?
Whenever a loading state is pure, repeating motion with no state to track — a spinner, a shimmer sweep, a bouncing dot — CSS @keyframes alone can drive it, running smoothly without any JavaScript polling. The moment a pattern needs to know something — how much progress has actually happened, whether a request succeeded or failed, when to swap one layer for another — that’s state, and state belongs in JavaScript, with CSS classes as the hand-off point between the two.
Every demo above ships its full HTML, CSS and JS in the HTML / CSS / JS tabs in its own top bar. The fastest way to actually internalize this is to click Fork & Edit on demo #7 — the optimistic UI one — and change the failure rate from 25% to 0%. Watch how much less “honest” it feels once the rollback path can never actually run; that’s the exact trade-off every optimistic interaction is making, just usually hidden from view. Once you’ve got a version you like, save it to My Code and it’s yours to keep, tweak further, or drop straight into a real project.
Recommended next step: open the CSS Playground and build a loading state of your own from scratch — start with a spinner, then try combining a skeleton with the crossfade technique from demo #8 on a layout you’re actually working on. For more posts like this one, browse the webdevpuneet.com blog.
Discover more from WebDevPuneet
Subscribe to get the latest posts sent to your email.




