Skip to content

Skeleton

A box that stands in for content that has not arrived. A sheen crosses it on `translate`, which the compositor resolves on its own, so the placeholder costs no paints while the real page is still loading. It has no size of its own: you give it the shape of the content it replaces.

  • Stable
  • React 19
  • Zero JS
  • Compositor-only animation

Three shapes and a layout — that is the whole component.

Code
<Skeleton style={{ inlineSize: '8rem', blockSize: '3rem' }} />
<div class="wayra-skeleton" data-shape="rect" aria-hidden="true" style="inline-size: 8rem; block-size: 3rem"></div>

One prop, and it is optional. Everything else about a skeleton is layout, and layout is yours: a placeholder only works when it has the size and position of the thing it replaces, and no size scale in a library can guess that.

Code
<Skeleton style={{ inlineSize: '8rem', blockSize: '3rem' }} />
<Skeleton shape="text" style={{ inlineSize: '8rem' }} />
<Skeleton shape="circle" style={{ inlineSize: '3rem' }} />
<div class="wayra-skeleton" data-shape="rect" aria-hidden="true" style="inline-size: 8rem; block-size: 3rem"></div>
<div class="wayra-skeleton" data-shape="text" aria-hidden="true" style="inline-size: 8rem"></div>
<div class="wayra-skeleton" data-shape="circle" aria-hidden="true" style="inline-size: 3rem"></div>
shape What it does
rect The default. A plain box with the theme’s selector radius. You give it both axes.
text 1em tall with half the radius, so it inherits the font size of the text it replaces.
circle Maximum radius and aspect-ratio: 1, so setting one axis sizes both.

text is the one that earns its keep. Because the height is 1em rather than a fixed number, the same markup is a heading placeholder inside a heading and a caption placeholder inside a caption — only the widths differ, and varied widths are what make a stack of them read as a paragraph.

The same two boxes at 1.5rem and at 0.875rem. Nothing was passed to resize them.

Code
<h2 style={{ fontSize: '1.5rem' }}>
<Skeleton shape="text" style={{ inlineSize: '70%' }} />
</h2>
<div style="font-size: 1.5rem; display: grid; gap: 0.5rem; inline-size: 18rem">
<div class="wayra-skeleton" data-shape="text" aria-hidden="true" style="inline-size: 70%"></div>
<div class="wayra-skeleton" data-shape="text" aria-hidden="true" style="inline-size: 100%"></div>
</div>
<div style="font-size: 0.875rem; display: grid; gap: 0.5rem; inline-size: 18rem">
<div class="wayra-skeleton" data-shape="text" aria-hidden="true" style="inline-size: 70%"></div>
<div class="wayra-skeleton" data-shape="text" aria-hidden="true" style="inline-size: 100%"></div>
</div>

This is the half of the component that is not CSS. A skeleton is aria-hidden by default: a screen reader walks past it as if the region were empty. What announces the wait is aria-busy on the region that is loading.

Code
<div aria-busy={pending}>
<Skeleton shape="text" style={{ inlineSize: '70%' }} />
<Skeleton shape="text" />
<Skeleton shape="text" style={{ inlineSize: '90%' }} />
</div>
<div aria-busy="true" style="display: grid; gap: 0.75rem; inline-size: 18rem">
<div class="wayra-skeleton" data-shape="text" aria-hidden="true" style="inline-size: 70%"></div>
<div class="wayra-skeleton" data-shape="text" aria-hidden="true" style="inline-size: 100%"></div>
<div class="wayra-skeleton" data-shape="text" aria-hidden="true" style="inline-size: 90%"></div>
</div>

One announcement for the region beats one per box. Five placeholders that each carry a label are five interruptions describing nothing, and a screen reader user cannot see the shapes that make the pattern readable in the first place.

It is also not a progressbar. A skeleton knows nothing about progress — it is a picture of a layout, and the honest ARIA for that is none at all. Button marks itself aria-busy for exactly the same reason when it is loading.

A skeleton animates at the exact moment the main thread is busy loading the real page, so the constraint is not which effect looks best — it is that the effect must not cost paints. The usual shimmer fails that: it sweeps a gradient with background-position, which the compositor cannot take, so the browser repaints the box every frame.

The sheen here is a separate box — an ::after pinned to inset: 0, carrying the gradient — and what moves is translate. That is compositable, so the sweep costs no more than a pulse would.

.wayra-skeleton::after {
content: "";
position: absolute;
inset: 0;
translate: -100%;
background: linear-gradient(100deg, transparent 30%, var(--wayra-skeleton-sheen) 50%, transparent 70%);
animation: wayra-skeleton-sheen 1.8s var(--_wayra-ease) infinite;
}
@keyframes wayra-skeleton-sheen {
to { translate: 100%; }
}

The gradient runs at 100deg rather than 90deg: a vertical edge crossing a wide box reads as a wipe, a slanted one as a sheen.

The sheen colour is neither white nor a single token. To read as a highlight rather than a shadow it has to be lighter than the base-300 fill, and the base scale inverts between the schemes — base-100 is the lightest colour in light and the darkest in dark — so no one token sits on the light side of the fill in both. light-dark() picks the one that does, the same mechanism the tokens themselves are built on:

--wayra-skeleton-sheen: light-dark(
color-mix(in oklch, var(--wayra-color-base-100) 70%, transparent),
color-mix(in oklch, var(--wayra-color-base-content) 10%, transparent)
);

The two alphas differ because base-content in dark is near-white and would blow out at the strength base-100 needs in light.

You can check the paint cost rather than take it on faith. Open DevTools → Rendering → Paint flashing on any example here: the boxes should never flash green. If they do, something is animating a property that costs paints.

Under prefers-reduced-motion: reduce the animation is dropped and the sheen rests off the leading edge, leaving a plain box — not a band frozen mid-sweep. It still reads as a placeholder, because the shape and the surface are what say “not loaded yet”; the sheen only says “still working”.

Code
<Card aria-busy>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Skeleton shape="circle" style={{ inlineSize: '3rem' }} />
<div style={{ display: 'grid', gap: '0.5rem', flex: 1 }}>
<Skeleton shape="text" style={{ inlineSize: '55%' }} />
<Skeleton shape="text" style={{ inlineSize: '85%' }} />
</div>
</div>
<Skeleton style={{ blockSize: '6rem' }} />
</Card>
<div class="wayra-card" aria-busy="true" style="inline-size: 20rem">
<div style="display: flex; align-items: center; gap: 1rem">
<div class="wayra-skeleton" data-shape="circle" aria-hidden="true" style="inline-size: 3rem"></div>
<div style="display: grid; gap: 0.5rem; flex: 1">
<div class="wayra-skeleton" data-shape="text" aria-hidden="true" style="inline-size: 55%"></div>
<div class="wayra-skeleton" data-shape="text" aria-hidden="true" style="inline-size: 85%"></div>
</div>
</div>
<div class="wayra-skeleton" data-shape="rect" aria-hidden="true" style="block-size: 6rem"></div>
</div>

The skeletons here carry no layout of their own — the flex row, the gap and the avatar’s 3rem come from the card that will replace them. Build the placeholder out of the loaded component’s own layout and the swap costs no reflow when the data lands.

SkeletonProps extends ComponentProps<"div"> minus color, so id, style, ref, className and every data-* land on the root box. There are no children in any example on this page on purpose: a placeholder is an empty box, not a wrapper.

Prop Type Default Description
shape "rect" | "text" | "circle" "rect" Corner and proportion treatment.
unstyled boolean false Drops the wayra-skeleton class, keeps data-shape.

The styling contract is the class plus the attribute — .wayra-skeleton[data-shape="text"] — and every snippet on this page is hand-written HTML using it, with no React involved.

  • Hidden by default. aria-hidden is on the box, before the spread, so it can be overridden per instance. Nothing else ARIA is set: no role, no label, no progressbar.
  • The region announces, not the box. Put aria-busy on the container that is loading, and put any visible “Loading…” text there too.
  • Contrast does not apply. The box is base-300 on base-100. WCAG 1.4.11 covers controls and meaningful graphics; a placeholder is decorative, and a skeleton with a hard border would read as a real, empty control.
  • Reduced motion drops the animation. The placeholder stays.
  • Focus never lands here. A skeleton is not focusable and adds nothing to the tab order.

Do

One aria-busy on the region. The boxes stay aria-hidden and say nothing.

Don't

Three progress bars that know no progress, announcing “Loading” three times for one wait.

Do

The placeholder has the shape of the content it replaces, so nothing jumps when the data lands.

Don't

One grey slab for an avatar and two lines. It loads into a layout the user was never shown.