Skeletons that don't shift: build the skeleton from the real content
Loading skeletons are everywhere, and most of them have the same bug: they're a separate set of grey rectangles, sized by hand to roughly match the real content. The data arrives, the real sizes don't agree with your guesses, and the layout jumps — the exact CLS you added a skeleton to avoid.
Motion shared a sharper take: use invisible placeholder content rather than manual sizing. Here's that idea in dependency-free CSS (scrub it frame by frame on the component page):
Sean Geng
@seangeng
Co-founder & CTO at B3. Building a crypto agent & decentralized inference.
Keep the real content mounted
The trick is that the real content never leaves the DOM. It's there from the first frame, fully laid out — you just paint over it while it's loading:
.reveal--loading .reveal__content
:where(h1,h2,h3,p,span,a,strong,button,[data-skel]) {
color: transparent !important; /* hide the text */
background-color: hsl(var(--muted)) !important; /* show a grey block */
background-image: linear-gradient(90deg, transparent,
hsl(var(--foreground)/.07) 50%, transparent) !important;
background-size: 220% 100% !important;
border-radius: 8px !important;
animation: reveal-shimmer 1.3s ease-in-out infinite;
}Because each block is the real element, it's already the right width and height. A heading that wraps to two lines makes a two-line skeleton. A button sized to its label makes a button-sized block. When the content reveals, there's nothing to resize — zero layout shift, for free.
You mark non-text shapes (avatars, stat tiles, the follow button) with
data-skel so they shimmer too; everything else is caught by the element
selector.
Wipe it in with a mask
When the data lands, don't just pop the content in — wipe it. An animated
mask-image slides a soft gradient across the content:
.reveal--revealing .reveal__content {
mask-image: linear-gradient(90deg, #000 0% 35%, transparent 70%);
mask-size: 300% 100%;
mask-repeat: no-repeat;
animation: reveal-wipe .7s ease forwards;
}
@keyframes reveal-wipe {
from { mask-position: 100% 0; opacity: .35; } /* content masked out */
to { mask-position: 0 0; opacity: 1; } /* fully revealed */
}A mask only carries alpha, so the wipe reveals the content's real colors —
gradients, images, everything — with no flash of a wrong background. Motion's
version drives this same mask through the browser's View Transition API;
plain CSS keyframes get you 90% of the effect with none of the setup, and it
degrades cleanly under prefers-reduced-motion (those users just get the
content).
Why this beats hand-sized skeletons
- No size drift. You never type a
w-32 h-4that's a few pixels off — the skeleton inherits exact dimensions from the content. - It can't go stale. Redesign the card and the skeleton updates with it, because it is the card.
- Less code. One wrapper instead of a parallel skeleton tree to maintain.
Takeaways
- Build the skeleton from the real, mounted content — exact sizes, zero CLS, nothing to keep in sync.
- Shimmer leaf elements with a moving
linear-gradient; mark shapes withdata-skel. - Reveal with an animated
mask-imagewipe (View Transition API optional), and respectprefers-reduced-motion.
Grab the component on the Skeleton Reveal page.
Ask your agent to implement this
Read the full writeup at https://seangeng.com/writing/skeletons-that-dont-shift.md and implement it in my project.
It covers: Skeletons that don't shift: build the skeleton from the real content — Most loading skeletons are hand-sized grey boxes that don't quite match the content — so the page jumps when data lands. Keep the real content mounted, shimmer it in place, and wipe it in with a mask. Zero layout shift, pure CSS.
Requirements:
- Follow the technique/approach exactly as described in the writeup.
- Adapt names, colors, and styling to my project's existing conventions.
- If it's a component, make it reusable with sensible props and TypeScript types.
- Keep it accessible: semantic HTML, keyboard support, and respect prefers-reduced-motion.
- When done, tell me which files you created or changed and how to use it.Paste into Claude Code, Codex, Cursor, or any agent. view raw .md