# Stable Skeletons

**MUST** · **ID:** `content-stable-skeletons` · **Category:** content
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/content-stable-skeletons

> MUST: Skeletons mirror final content to avoid layout shift

Skeletons mirror final content exactly to avoid layout shift

Loading skeletons should match the dimensions and layout of the actual content. This prevents cumulative layout shift (CLS) when content loads. If content varies significantly, use the most common dimensions or a safe maximum. Two checks catch most of what "matches the dimensions" actually means. First, an image placeholder needs the correct **aspect ratio**, not merely some height: a 200px-tall grey box standing in for a 16:9 photo still reflows the column the moment the real image arrives, so reserve the box with `aspect-ratio` (or explicit `width`/`height`) taken from the asset. Second, a text skeleton bar must match the typography's **line-box height**, not its font-size: `text-sm` is 14px of glyph inside a 20px line box, so a 14px bar is 6px short per line and a three-line paragraph shifts everything below it by 18px. Size the bars from the computed `line-height` and keep the same gaps the real lines will have.

## Bad — do not do this

`content-stable-skeletons-bad`

```tsx
import { useState, useEffect } from 'react';

export function StableSkeletonsBad() {
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setTimeout(() => setLoading(false), 2000);
  }, []);

  if (loading) {
    return (
      <div className="w-full max-w-sm">
        <div className="bg-muted rounded h-4 w-32 animate-pulse mb-2" />
        <div className="bg-muted rounded h-3 w-full animate-pulse" />
      </div>
    );
  }

  return (
    <div className="w-full max-w-sm">
      <h3 className="text-lg font-semibold mb-2">Article Title Goes Here</h3>
      <p className="text-sm text-muted-foreground">
        This is a longer paragraph of content that loads after the skeleton. The height difference causes layout shift.
      </p>
      <p className="text-xs text-error mt-4">
        Skeleton dimensions don't match, causing layout shift
      </p>
    </div>
  );
}
```

## Good — do this

`content-stable-skeletons-good`

```tsx
import { useState, useEffect } from 'react';

export function StableSkeletonsGood() {
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setTimeout(() => setLoading(false), 2000);
  }, []);

  if (loading) {
    return (
      <div className="w-full max-w-sm">
        <div className="bg-muted rounded h-7 w-48 animate-pulse mb-2" />
        <div className="space-y-2">
          <div className="bg-muted rounded h-4 w-full animate-pulse" />
          <div className="bg-muted rounded h-4 w-full animate-pulse" />
          <div className="bg-muted rounded h-4 w-3/4 animate-pulse" />
        </div>
      </div>
    );
  }

  return (
    <div className="w-full max-w-sm">
      <h3 className="text-lg font-semibold mb-2">Article Title Goes Here</h3>
      <p className="text-sm text-muted-foreground">
        This is a longer paragraph of content that loads after the skeleton. The skeleton matched these dimensions exactly.
      </p>
      <p className="text-xs text-success mt-4">
        Skeleton mirrors final content, no layout shift
      </p>
    </div>
  );
}
```

## References

- [Skeleton Screens](https://www.nngroup.com/articles/skeleton-screens/)
- [Cumulative Layout Shift](https://web.dev/articles/cls)
- [Optimize CLS](https://web.dev/articles/optimize-cls)
