Skeleton
A grey placeholder in the shape of the content that is loading, so the page does not jump when it arrives. It is never in the shape of a number.
Preview
skeleton · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/skeleton
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { Skeleton } from "@/components/ui/skeleton"<Skeleton shape="text" lines={3} appearAfterMs={200} />When to use it
Use it when
- Content whose size and position are known in advance, arriving within roughly a second, where the alternative is the layout jumping when it lands.
- Lists and grids where a known number of items is loading and the reader benefits from seeing the shape of what is coming.
- Any surface where a spinner would replace a whole screen with nothing.
Do not use it when
- There is nothing coming. The surface is genuinely empty.Use
empty-stateinstead. - Loading did not complete.Use
alert-bannerinstead. - The wait is long enough that the reader needs to know it is a long wait.Use
calloutinstead. - The shape and size of what is coming are unknown, so the skeleton would guess.Use
spinnerinstead. - A single control is busy after the reader pressed it.Use
buttoninstead.
Anatomy
SkeletonThe root. aria-hidden, hidden from print, and the element the appearance delay applies to. Never focusable, never announced.Controlled byappearAfterMsSkeleton.GroupPresent only for the text shape. Stacks the lines to take the height N lines of body text will.Controlled bylinesSkeleton.BlockOne placeholder shape: placeholder-fill over a placeholder-line hairline. Carries the shimmer, which reduced motion removes.Controlled byshape
- Skeleton 1data-slot="skeleton", aria-hidden, print:hidden
- Skeleton.Group 0..1data-slot="skeleton-group"; the text shape only
- Skeleton.Block 1..ndata-slot="skeleton-block"; one per line, the last narrower when there is more than one
- Skeleton.Block 0..1data-slot="skeleton-block"; the single-shape form, which is a line, a block or a circle
- Skeleton.Group 0..1data-slot="skeleton-group"; the text shape only
Examples
A list that is still arriving
Three rows of skeletons, each root aria-hidden, with one polite role="status"
message mounted by the caller beside the busy list rather than inside it. Every
skeleton passes appearAfterMs, so a warm cache never shows one.
skeleton-a-loading-list · base base · style base-lyraOpen under the product themeA row that may turn out to be empty
The label gets a placeholder because every row has one. The value's seat gets height and no shape, because whether a reading exists is what the request will answer.
skeleton-a-row-that-may-be-empty · base base · style base-lyraOpen under the product themeAccessibility
Audited against WCAG 2.2 AA in a source pass and a rendered pass. The audit is author-run, not an independent review, and clinical review is still pending. The rendered pass found no violation, no target-size hit, no missing focus-visible state and no reflow failure at 320px. The source pass found no defect to fix, so this audit made no change to the component.
Checked by pnpm run check:a11y on every commit. No status colour can
reach a skeleton, every colour is a theme role (bg-placeholder-fill and
border-placeholder-line), and no type size is in px.
Measured by pnpm run contrast. The placeholder line clears the non-text
floor against the page, a card and the muted ground in both themes; against its
own fill it sits below Lc 45 in the dark theme, and the fill itself is published
as advisory.
Argued from the source, not measured.
- The root is
aria-hiddenand mounts no live region on purpose. A list of many skeletons should be announced once, not once per block, so the caller owns onerole="status"message beside the region rather than inside it. Both examples on this page already show that pattern. - The shimmer is declared inside a
prefers-reduced-motion: no-preferencequery, so under reduce it never runs and the static tint remains. - The shimmer runs two cycles, about 3.2 seconds, then stops on the static tint, so SC 2.2.2 does not engage. The appearance delay is never suppressed.
- Nothing is focusable or interactive, and the root is
print:hidden. - There is no
forced-colorsorprefers-contrastoverride. The root is decorative andaria-hidden, so forced-colors mode has no information here that an override would need to preserve.
Not checked by any gate. The 200% and 1.3x layout claims, what a screen
reader announces, the dark-theme boundary on its own fill, and the text
rhythm on a browser without the lh unit.
| Key | Action | Notes |
|---|---|---|
| Tab | Skips the skeleton | Nothing inside one is focusable. A stop that vanishes on load is worse than none. |
| Shift+Tab | Skips the skeleton | Same in reverse, at any shape, with or without the shimmer. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from SkeletonProps in registry/bases/base/skeleton.tsx.
Skeleton is a server component: appearAfterMs is a CSS animation-delay,
not a timer, so the placeholder holds the layout before hydration. A shape
outside the four resolves to text. lines is clamped to 1 to 24. No part is
exported; style them through data-slot.
Related
- EmptyState is for when nothing is coming. Confusing the two misleads the reader about whether their data exists.
- Spinner is for waits whose shape is unknown, where a skeleton has no single outline to promise.
- Surface is what skeletons frequently sit on, and where reduced transparency and reduced motion interact.
- Button owns its own busy state; never replace a pressed button with a skeleton.