Skip to content
← writing

Skeletons that don't shift: build the skeleton from the real content

css
loading
performance
frontend

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. Then the data arrives, the real sizes don't agree with your guesses, and the layout jumps. Which is the exact CLS you added a skeleton to avoid.

Motion shared a sharper take: use invisible placeholder content instead of manual sizing. Here's that idea in dependency-free CSS (scrub it frame by frame on the component page):

Sean Geng

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.

Non-text shapes (avatars, stat tiles, the follow button) get a data-skel attribute 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 and images and all, 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 most of the effect with none of the setup, and they degrade cleanly under prefers-reduced-motion (those users just get the content, no wipe).

Why this beats hand-sized skeletons

Three reasons, really. There's no size drift, because the skeleton inherits its dimensions from the content instead of from a w-32 h-4 you typed once and forgot. It can't go stale: redesign the card and the skeleton redesigns itself, because it is the card. And it's less code, one wrapper instead of a parallel skeleton tree you have to keep in sync.

That last point is the real win for me. Every hand-built skeleton I've shipped eventually drifted from the thing it was standing in for. This one can't.

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 download source .zip