Feedback
Skeleton
The shape of the page, before the page.
Usage
When to reach for it
import { Skeleton } from '@misoto22/design'Notes
The fill every skeleton part is drawn from: --stone, at the height of one line until something says otherwise.
Deliberately NOT a shimmer. A shimmer sweeps a highlight across the shape, which implies a light source; the White Reset has none, so the sweep reads as a bug rather than as loading. The whole page breathes together instead — see SkeletonPage, which owns the single pulse.
h-3 is a default rather than a design. A <div> is already full width, so height was the one dimension a caller could omit and get nothing for — a bare <Skeleton /> was a zero-height box, which is not a small skeleton but an absent one. Twelve pixels is the height SkeletonLine already chose, and any class the caller passes replaces it.
Anatomy
| Element | Description |
|---|---|
| Framerequired | SkeletonPage: the div carrying role="status", aria-busy="true" and the single pulse. It is the only part that speaks and the only part that moves — the shapes inside it do neither. |
| Labelrequired | The sr-only sentence inside the frame, from the required label prop. Every shape is aria-hidden, so this one sentence is the entire loading state for a screen reader. |
| Fill | Skeleton itself: a --stone rectangle one line tall — h-3 — and, being a div, already full width. Height is the one dimension the element does not have on its own, so that is the one the base supplies; anything in className replaces it. |
| Line, Block and Circle | The three presets over that fill. SkeletonLine is a 12px pill and takes its width from the caller, SkeletonBlock only sets --radius-sm, and SkeletonCircle is a fixed 36px round. |
| Paragraph | SkeletonText: three lines by default, the last at 62% so it ends mid-measure the way prose does rather than squaring off into a table. |
Best practices
Do
- Wrap the shapes in SkeletonPage even when there is only one bar. The role, the aria-busy and the pulse all live on the frame, so parts used loose are aria-hidden, silent and completely still — a grey rectangle that never resolves.
- Give every Skeleton the height of the thing it stands in for. The base falls back to one line, so a block left unsized is no longer invisible — it is a 12px bar where a 160px plate is about to land, and the page jumps by the difference.
- Make whatever replaces the skeleton announce itself or take focus. aria-busy never flips to false here — the frame is unmounted, not updated — so the end of the wait is the disappearance of the only thing that was speaking.
Don’t
- Do not add animate-pulse to a part. The frame already animates opacity and a second ramp on a child multiplies with it, so the part beats at a rate the shape around it does not. Reduced motion is not the reason it is wrong: the rule in keyframes.css is a universal floor on animation-duration and transition-duration, so a hand-written Tailwind animation is clamped along with everything else. data-m22-animated is a component asserting its motion is decorative, not the mechanism that does the cancelling.
- Do not nest one SkeletonPage inside another. Each is a role="status" region with its own sr-only sentence, so a page assembled from two skeleton sections announces two loading messages and marks two regions busy for a single wait.
- Do not carry a skeleton between screens unedited. SkeletonCircle is 36px and SkeletonLine is 12px tall whatever they stand in for, so a copied avatar mount and a copied heading are two guaranteed reflows the moment the real content lands.
Examples
record list
The skeleton for the list that replaces it, built on the same grid — a 9rem plate beside a kicker, a title and two lines of prose. That match is the whole job: a skeleton whose shape differs from what lands is a layout shift the reader was warned about and then subjected to anyway. One SkeletonPage wraps the lot, so there is one live region, one sentence read aloud and one pulse rather than eight bars breathing out of phase. Nothing here flips aria-busy to false — the frame is unmounted, not updated — so whatever replaces it has to announce itself or take focus.
parts
The four presets over the one fill. Skeleton itself sets a --stone ground and nothing else — no height, no width, no radius — so a bare one renders a zero-height div and shows nothing at all; every dimension comes from className. SkeletonCircle is a fixed 36px and SkeletonLine a fixed 12px tall whatever they stand in for, which is why a skeleton copied between screens has to be re-measured against what it now replaces. Do not add animate-pulse to a part: the frame already animates opacity, and a second ramp on a child multiplies with it.
shape match
The same comment twice: the shape that holds its place, and the thing that lands in it. SkeletonCircle is 36px and Avatar at the default size is 36px, the name line stands at the height of the caption that replaces it, and the two prose bars end where the real sentence ends — so nothing on the page moves at the moment of the swap. A skeleton measured against nothing is a layout shift with extra steps: it promises a layout, and then the page jumps out from under the reader who was already reading it.
While it loads
What lands
Henry Chen
Rebased onto main and the typecheck is green again. Merging once CI finishes.
Parts
Composed at the call site rather than configured through props, so a layout this component did not anticipate is still expressible.
SkeletonLine
A line of type. Height is the line's; width is how far it runs.
Also accepts everything in HTMLAttributes<HTMLDivElement>. Those are forwarded to the underlying element and are not listed row by row.
SkeletonBlock
A block: an image mount, a plate, a chart.
Also accepts everything in HTMLAttributes<HTMLDivElement>. Those are forwarded to the underlying element and are not listed row by row.
SkeletonCircle
A circle: an avatar, a marker.
Also accepts everything in HTMLAttributes<HTMLDivElement>. Those are forwarded to the underlying element and are not listed row by row.
SkeletonText
A paragraph.
The last line is short, because real prose ends mid-measure. A block of equal full-width bars reads as a table, and the reader then flinches when a paragraph arrives instead.
| Prop | Type | Default | Description |
|---|---|---|---|
| lines | number | 3 | How many lines of prose to stand in for. |
Also accepts everything in HTMLAttributes<HTMLDivElement>. Those are forwarded to the underlying element and are not listed row by row.
SkeletonPage
The frame a skeleton sits in: the live region, and the single pulse.
One animate-pulse on the wrapper rather than one per part, so the page breathes together instead of shimmering out of phase — twenty independently animated bars is a visual noise floor, not a loading state.
A skeleton whose shape does not match what replaces it is worse than none: it promises a layout and then the page jumps out from under the reader. Compose the parts to match the real thing, and change both together.
| Prop | Type | Default | Description |
|---|---|---|---|
| childrenrequired | ReactNode | — | |
| labelrequired | string | What is loading, in words. Announced once; the shapes themselves are `aria-hidden`, so this sentence is the entire experience for a screen reader and "Loading" alone tells it nothing. |
Also accepts everything in HTMLAttributes<HTMLDivElement>. Those are forwarded to the underlying element and are not listed row by row.
Accessibility
- One live region on the wrapper; every shape inside it is aria-hidden.
- One pulse on the wrapper, not one per bar, so the page breathes together.