opsinjs
ComponentsFeedback

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

Theme
Density
Text
Status
skeleton · base base · style base-lyraOpen under the product theme

Installation

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-state instead.
  • Loading did not complete.Use alert-banner instead.
  • The wait is long enough that the reader needs to know it is a long wait.Use callout instead.
  • The shape and size of what is coming are unknown, so the skeleton would guess.Use spinner instead.
  • A single control is busy after the reader pressed it.Use button instead.

Anatomy

  1. SkeletonThe root. aria-hidden, hidden from print, and the element the appearance delay applies to. Never focusable, never announced.Controlled by appearAfterMs
  2. Skeleton.GroupPresent only for the text shape. Stacks the lines to take the height N lines of body text will.Controlled by lines
  3. Skeleton.BlockOne placeholder shape: placeholder-fill over a placeholder-line hairline. Carries the shimmer, which reduced motion removes.Controlled by shape
  • 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

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.

Theme
Density
Text
Status
skeleton-a-loading-list · base base · style base-lyraOpen under the product theme

A 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.

Theme
Density
Text
Status
skeleton-a-row-that-may-be-empty · base base · style base-lyraOpen under the product theme

Accessibility

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-hidden and mounts no live region on purpose. A list of many skeletons should be announced once, not once per block, so the caller owns one role="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-preference query, 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-colors or prefers-contrast override. The root is decorative and aria-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.

KeyActionNotes
TabSkips the skeletonNothing inside one is focusable. A stop that vanishes on load is worse than none.
Shift+TabSkips the skeletonSame in reverse, at any shape, with or without the shimmer.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below 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.

  • 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.

On this page