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.
Shapes
Section titled “Shapes”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>The loading state goes on the container
Section titled “The loading state goes on the container”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 shimmer that does not repaint
Section titled “A shimmer that does not repaint”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”.
Composing a loading state
Section titled “Composing a loading state”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.
API reference
Section titled “API reference”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.
Accessibility
Section titled “Accessibility”- Hidden by default.
aria-hiddenis on the box, before the spread, so it can be overridden per instance. Nothing else ARIA is set: norole, no label, noprogressbar. - The region announces, not the box. Put
aria-busyon the container that is loading, and put any visible “Loading…” text there too. - Contrast does not apply. The box is
base-300onbase-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 and don’t
Section titled “Do and don’t”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.