---
title: "Skeleton"
description: "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."
url: "https://opsinjs.pensievelabs.org/components/skeleton"
source: "https://opsinjs.pensievelabs.org/components/skeleton.md"
section: "Components"
status: "shipped"
kind: "component"
category: "feedback"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["loading placeholder", "shimmer", "loading state", "placeholder", "ghost"]
usedIn: ["choose-a-component", "daily-log-screen", "empty-and-first-use", "offline-and-stale-data", "results-screen", "trends-screen"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="skeleton"
  status="shipped"
  questions="[
  &#x22;Nothing in opsinjs owns the long end of a wait: past the few seconds Data states allows, a skeleton should have become words, and no threshold is measured.&#x22;,
  &#x22;Shimmer defaults to on because a motionless rectangle can read as stalled content, and nobody has watched an anxious reader look at either state.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="skeleton" />

## Installation [#installation]

<ComponentInstall name="skeleton" unbuilt="false" importPath="@/components/ui/skeleton" />

## Usage [#usage]

```tsx
import { Skeleton } from "@/components/ui/skeleton"
```

```tsx
<Skeleton shape="text" lines={3} appearAfterMs={200} />
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Content whose size and position are known in advance, arriving within roughly a second, where the alternative is the layout jumping when it lands.&#x22;,
  &#x22;Lists and grids where a known number of items is loading and the reader benefits from seeing the shape of what is coming.&#x22;,
  &#x22;Any surface where a spinner would replace a whole screen with nothing.&#x22;,
]"
  avoid="[
  { case: &#x22;There is nothing coming. The surface is genuinely empty.&#x22;, instead: &#x22;empty-state&#x22; },
  { case: &#x22;Loading did not complete.&#x22;, instead: &#x22;alert-banner&#x22; },
  { case: &#x22;The wait is long enough that the reader needs to know it is a long wait.&#x22;, instead: &#x22;callout&#x22; },
  { case: &#x22;The shape and size of what is coming are unknown, so the skeleton would guess.&#x22;, instead: &#x22;spinner&#x22; },
  { case: &#x22;A single control is busy after the reader pressed it.&#x22;, instead: &#x22;button&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="skeleton"
  parts="[
  {
    name: &#x22;Skeleton&#x22;,
    describes: &#x22;The root. aria-hidden, hidden from print, and the element the appearance delay applies to. Never focusable, never announced.&#x22;,
    prop: &#x22;appearAfterMs&#x22;,
  },
  {
    name: &#x22;Skeleton.Group&#x22;,
    describes: &#x22;Present only for the text shape. Stacks the lines to take the height N lines of body text will.&#x22;,
    prop: &#x22;lines&#x22;,
  },
  {
    name: &#x22;Skeleton.Block&#x22;,
    describes: &#x22;One placeholder shape: placeholder-fill over a placeholder-line hairline. Carries the shimmer, which reduced motion removes.&#x22;,
    prop: &#x22;shape&#x22;,
  },
]"
/>

<CompositionTree
  name="skeleton"
  tree="[
  {
    part: &#x22;Skeleton&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;data-slot=\&#x22;skeleton\&#x22;, aria-hidden, print:hidden&#x22;,
    children: [
      {
        part: &#x22;Skeleton.Group&#x22;,
        cardinality: &#x22;0..1&#x22;,
        note: &#x22;data-slot=\&#x22;skeleton-group\&#x22;; the text shape only&#x22;,
        children: [
          {
            part: &#x22;Skeleton.Block&#x22;,
            cardinality: &#x22;1..n&#x22;,
            note: &#x22;data-slot=\&#x22;skeleton-block\&#x22;; one per line, the last narrower when there is more than one&#x22;,
          },
        ],
      },
      {
        part: &#x22;Skeleton.Block&#x22;,
        cardinality: &#x22;0..1&#x22;,
        note: &#x22;data-slot=\&#x22;skeleton-block\&#x22;; the single-shape form, which is a line, a block or a circle&#x22;,
      },
    ],
  },
]"
/>

## Examples [#examples]

### A list that is still arriving [#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.

<ComponentPreview name="skeleton-a-loading-list" kind="example" align="start" />

### A row that may turn out to be empty [#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.

<ComponentPreview name="skeleton-a-row-that-may-be-empty" kind="example" align="start" />

## Accessibility [#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.

<KeyboardTable
  name="skeleton"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Skips the skeleton&#x22;,
    notes: &#x22;Nothing inside one is focusable. A stop that vanishes on load is worse than none.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Skips the skeleton&#x22;,
    notes: &#x22;Same in reverse, at any shape, with or without the shimmer.&#x22;,
  },
]"
/>

<ContrastReport component="skeleton" />

## API reference [#api-reference]

<PropsTable name="SkeletonProps" />

`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 [#related]

* [EmptyState](./empty-state.mdx) is for when nothing is coming. Confusing the
  two misleads the reader about whether their data exists.
* [Spinner](./spinner.mdx) is for waits whose shape is unknown, where a skeleton
  has no single outline to promise.
* [Surface](./surface.mdx) is what skeletons frequently sit on, and where reduced
  transparency and reduced motion interact.
* [Button](./button.mdx) owns its own busy state; never replace a pressed button
  with a skeleton.
