---
title: "Skeletons that don't shift: build the skeleton from the real content"
description: "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."
date: "2026-05-29"
tags: ["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. The data arrives, the real sizes don't agree with your guesses, and the
layout jumps — the exact [CLS](https://web.dev/cls/) you added a skeleton to
avoid.

[Motion](https://motion.dev) 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](/components/skeleton-reveal)):

## 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:

```css
.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:

```css
.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-4` that'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 with
  `data-skel`.
- Reveal with an animated `mask-image` wipe (View Transition API optional), and
  respect `prefers-reduced-motion`.

Grab the component on the [Skeleton Reveal](/components/skeleton-reveal) page.
