Loading & skeleton states
A skeleton that mirrors the real layout makes waiting feel shorter and prevents the layout jump a spinner can't.
In one line: while data loads, show grey blocks shaped exactly like the content that's coming — the wait feels shorter, and nothing jumps when the real thing arrives.
What it is
A skeleton is a placeholder that traces the shape of content before that content exists. Instead of a spinner floating in empty space, you render the page's actual layout — an avatar circle, a name line, a couple of text rows — as plain grey blocks. When the data lands, each block is replaced in place by the real element it stood in for.
A faint animation usually rides on top: a shimmer, a soft highlight that sweeps left to right, or a pulse, a gentle fade in and out of opacity. The animation says "still working," the shapes say "here's what's coming."
Why it matters
A spinner answers one question — is it loading? — and tells you nothing about what you're waiting for or where it will go. A skeleton answers three: it's loading, here's roughly what arrives, and it lands there. That preview gives your eye something to settle on, which is why a skeleton-filled wait reads as shorter than a blank one of the same length, even though the clock disagrees.
The bigger win is structural. Because a skeleton occupies the exact footprint of the loaded content, the swap costs zero layout shift: text doesn't reflow, buttons don't slide out from under the cursor. A spinner that's later replaced by a tall card does the opposite — it shoves everything down the instant data arrives. Skeletons make the loading state part of the layout instead of a hole punched through it.
See it
Tweak it2
How it works
Four ideas carry the whole pattern:
- Mirror the layout. Build the skeleton from the same boxes as the real card — same avatar diameter, same line heights, same button width. The closer the match, the more invisible the swap.
- Reserve the space. Give every placeholder a fixed size so the container's height is identical loading and loaded. This is what kills the jump; it's also what keeps cumulative layout shift at zero.
- Shimmer vs. pulse. Shimmer animates
background-positionto slide a highlight gradient across the block — lively, draws the eye. Pulse animatesopacity— quieter, cheaper, easy to make subtle. Both run only on properties the compositor handles, so neither reflows anything. - When a spinner is still fine. For a short, indeterminate wait with no known shape — a form submitting, a single icon button working — a spinner is honest and simpler. Reach for a skeleton when you know the layout that's coming and there's enough of it to preview.
Build it
/* Each placeholder reserves real space, so loading and loaded are the
same height — no layout shift on swap. */
.skeleton {
border-radius: 0.375rem;
background-color: #e5e7eb;
}
.skeleton-avatar { width: 3rem; height: 3rem; border-radius: 9999px; }
.skeleton-line { height: 0.75rem; }
.skeleton-line.is-name { width: 60%; }
.skeleton-line.is-full { width: 100%; }
.skeleton-button { width: 7rem; height: 2.25rem; }
/* SHIMMER: slide a soft highlight across via background-position only. */
.skeleton.shimmer {
background-image: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.6) 50%,
transparent 100%
);
background-size: 200% 100%;
background-repeat: no-repeat;
animation: skeleton-sweep 1.4s ease-in-out infinite;
}
@keyframes skeleton-sweep {
0% { background-position: 150% 0; }
100% { background-position: -150% 0; }
}
/* PULSE: a softer alternative — opacity only. */
.skeleton.pulse {
animation: skeleton-fade 1.4s ease-in-out infinite;
}
@keyframes skeleton-fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* a11y: stop the motion; the static grey blocks still convey "loading". */
@media (prefers-reduced-motion: reduce) {
.skeleton.shimmer,
.skeleton.pulse {
animation: none;
background-image: none;
}
}import { useEffect, useState } from "react";
// A single placeholder block. Pass the same className you'd give the real
// element so the skeleton reserves identical space.
function Skeleton({ className, style = "shimmer" }) {
return <span aria-hidden className={`skeleton ${style} ${className}`} />;
}
export function ProfileCard({ user, style = "shimmer" }) {
const loading = !user;
return (
// aria-busy tells assistive tech the region is still loading.
<article aria-busy={loading} className="card">
{loading ? (
<div className="card-body">
<Skeleton className="skeleton-avatar" style={style} />
<Skeleton className="skeleton-line is-name" style={style} />
<Skeleton className="skeleton-line is-full" style={style} />
<Skeleton className="skeleton-line is-full" style={style} />
<Skeleton className="skeleton-button" style={style} />
</div>
) : (
<div className="card-body">
<img className="avatar" src={user.avatar} alt="" />
<p className="name">{user.name}</p>
<p className="bio">{user.bio}</p>
<button type="button">Follow</button>
</div>
)}
</article>
);
}
// Demo harness: flip to loaded after a beat.
export function Example() {
const [user, setUser] = useState(null);
useEffect(() => {
const t = setTimeout(
() => setUser({ name: "Rosa Pinheiro", bio: "Builds small things.", avatar: "/rosa.jpg" }),
1500,
);
return () => clearTimeout(t);
}, []);
return <ProfileCard user={user} />;
}Make it yours
Use the controls beside the demo above to change loading and idle style — each change updates the example live.
Experiment in the playgroundReproduce it with an LLM
Reproduce it with an LLM
You are a senior front-end engineer. Build a loading state for a profile card (avatar, name, two lines of text) as a skeleton that mirrors the card's real layout: grey placeholder blocks in the exact positions and sizes of the eventual content, with a gentle left-to-right shimmer. Build it in React. Requirements: the skeleton must reserve the same space as the loaded content so there is no layout shift when it swaps in; expose the loading state via aria-busy on the container and hide the decorative skeleton from screen readers; the shimmer must stop (show a static skeleton) under prefers-reduced-motion: reduce; animate only background-position or transform. Return only the component code with imports and CSS.
Pitfalls & accessibility
Related
Scroll-driven reveals
Let content fade and rise into place as it enters the viewport — an enhancement that must never hide content when JavaScript doesn't run.
Toast / notification motion
A toast should arrive with a spring and leave with a quiet ease — and announce itself to screen readers without stealing focus.
Anatomy of a micro-interaction
Every small feedback moment has four parts — trigger, rules, feedback, and loops. Name them and you can design any of them on purpose.
Further reading
- Cumulative Layout Shift in the Core Web Vitals, for why reserving space during load is a measured quality signal, not just a nicety.
- Your animation library's notes on which CSS properties stay on the compositor — the list that decides whether a shimmer is cheap or costly.