# Skeleton

The shape of the page, before the page.

- Group: Feedback
- Import: `import { Skeleton } from '@misoto22/folio'`
- Page: https://ui.misoto22.com/components/skeleton/
- Related: spinner, empty-state

## When to reach for it

A wait the reader would otherwise think was a broken page. A shape that describes what is coming beats a dot that describes nothing.

## Anatomy

- **Frame** (required) — 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.
- **Label** (required) — 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-folio-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.

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

## Skeleton

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.

Also accepts: `HTMLAttributes<HTMLDivElement>`.

## SkeletonLine

A line of type. Height is the line's; width is how far it runs.

Also accepts: `HTMLAttributes<HTMLDivElement>`.

## SkeletonBlock

A block: an image mount, a plate, a chart.

Also accepts: `HTMLAttributes<HTMLDivElement>`.

## SkeletonCircle

A circle: an avatar, a marker.

Also accepts: `HTMLAttributes<HTMLDivElement>`.

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

### Props

- `lines` — `number` default `3`. How many lines of prose to stand in for.

Also accepts: `HTMLAttributes<HTMLDivElement>`.

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

### Props

- `label` (required) — `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.
- `children` (required) — `ReactNode`.

Also accepts: `HTMLAttributes<HTMLDivElement>`.

## Example — record list

```tsx
import { SkeletonBlock, SkeletonLine, SkeletonPage, SkeletonText } from '@misoto22/folio'

<SkeletonPage label="Loading projects">
  <div className="border-t border-(--rule-hard)">
    {[0, 1].map((row) => (
      <div key={row} className="grid gap-x-8 gap-y-4 border-b border-(--rule) py-6 sm:grid-cols-[9rem_minmax(0,1fr)]">
        <SkeletonBlock className="h-24" />
        <div>
          <SkeletonLine className="h-2.5 w-24" />
          <SkeletonBlock className="mt-4 h-5 w-[min(100%,16rem)]" />
          <SkeletonText className="mt-5" lines={2} />
        </div>
      </div>
    ))}
  </div>
</SkeletonPage>
```

## Example — parts

```tsx
import { SkeletonBlock, SkeletonCircle, SkeletonLine, SkeletonPage, SkeletonText } from '@misoto22/folio'

<SkeletonPage label="Loading the example" className="flex flex-col gap-5">
  <div className="flex items-center gap-3">
    <SkeletonCircle />
    <SkeletonLine className="w-32" />
  </div>
  <SkeletonBlock className="h-20" />
  <SkeletonText lines={3} />
</SkeletonPage>
```

## Example — shape match

```tsx
import { Avatar, SkeletonBlock, SkeletonCircle, SkeletonLine, SkeletonPage, Text } from '@misoto22/folio'

<div className="grid w-full gap-8 sm:grid-cols-2">
  <div className="flex flex-col gap-3">
    <Text size="xs" tone="muted">
      While it loads
    </Text>
    <SkeletonPage label="Loading the comment" className="flex gap-3">
      <SkeletonCircle />
      <div className="min-w-0 flex-1">
        <SkeletonLine className="h-2.5 w-24" />
        <SkeletonBlock className="mt-3 h-4 w-full" />
        <SkeletonBlock className="mt-2 h-4 w-[62%]" />
      </div>
    </SkeletonPage>
  </div>
  <div className="flex flex-col gap-3">
    <Text size="xs" tone="muted">
      What lands
    </Text>
    <div className="flex gap-3">
      <Avatar alt="" fallback="HC" />
      <div className="min-w-0 flex-1">
        <Text size="xs" tone="muted">
          Henry Chen
        </Text>
        <Text size="sm" className="mt-2">
          Rebased onto main and the typecheck is green again. Merging once CI finishes.
        </Text>
      </div>
    </div>
  </div>
</div>
```
