Skeleton
Placeholder loading component with pulse animation for content that is loading
The Skeleton component renders a pulsing placeholder that mimics the shape of content being loaded. It has no props of its own — control size and shape entirely through className.
Each Skeleton is aria-hidden, so a surface built from them is silent to assistive tech. Wrap the surface in SkeletonGroup to announce the load once, for the whole region.
Import
import { Skeleton, SkeletonGroup } from '@nim-ui/components';Basic Usage
Code
<div className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>Common Patterns
Card Skeleton
Code
<div className="space-y-3 rounded-lg border p-4">
<Skeleton className="h-40 w-full rounded-md" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-9 w-24" />
</div>Avatar + Text Skeleton
Code
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>Props
| Name | Type | Default | Description |
|---|---|---|---|
className | string | - | Controls width, height, and shape (e.g., h-4 w-48 rounded-full) |
The Skeleton component accepts all standard div HTML attributes.
aria-hidden="true" is applied before the prop spread, so it is a default rather than a contract — pass aria-hidden={false} if one particular placeholder really must be exposed. Never put focusable content inside a Skeleton: a focusable element under an aria-hidden ancestor is the axe aria-hidden-focus violation and a real keyboard trap.
SkeletonGroup
SkeletonGroup is the loading surface, and the surface is what owes the status message. It renders one role="status" live region as a sibling of the aria-busy content host, swaps fallback for children when loading flips, and never unmounts the region in between.
Skeleton itself is deliberately not a live region: a real surface shows 2–5 of them, which would mint 2–5 competing announcements per load.
Code
function OperatorCard({ operator, isLoading }: Props) {
return (
<SkeletonGroup
loading={isLoading}
label="Loading operator"
fallback={
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-28" />
</div>
</div>
}
>
<div className="flex items-center gap-3">
<Avatar src={operator.avatar} />
<div>
<p className="font-medium">{operator.name}</p>
<p className="text-sm text-neutral-500">{operator.email}</p>
</div>
</div>
</SkeletonGroup>
);
}Announcing completion
loadedLabel is unset by default, and the demo above shows that default: when loading ends the region's text goes to '' and nothing is announced. Set it and the region announces completion instead.
Neither behaviour is visible on screen and neither is observable from the DOM — both spellings mount the same live region and keep the same node across the transition. The difference exists only in what a screen reader says, so the two demos sit here to be compared with one running.
Code
<SkeletonGroup
loading={isLoading}
label="Loading operator"
loadedLabel="Operator loaded"
fallback={<OperatorSkeleton />}
>
<OperatorCard operator={operator} />
</SkeletonGroup>Layout classes belong inside fallback and children. SkeletonGroup ships none of its own, and className lands on the root wrapper only — so space-y-* on the group would space the live region, not your rows.
Not inside a table
SkeletonGroup renders a <div>, and so does Skeleton — which is fine around a table and impossible inside one. Neither <tbody> nor <tr> accepts a <div>, and the parser foster-parents it out of the table rather than tolerating it, so the placeholder renders above the table and server markup fails to hydrate.
A <td> does take flow content, so a Skeleton inside a cell is valid. Use DataTable's own loading prop for the announcement — it renders the live region as a sibling of the table, and you render one Skeleton per DataTableCell.
Don't: return early and unmount the region
The obvious shape. It works visually and announces nothing: the live region is created at the same moment its text arrives, which screen readers handle inconsistently, and it disappears again the instant loading ends.
// ✗ Don't — the loading region is mounted and unmounted with the skeletons
function OperatorCard({ operator, isLoading }: Props) {
if (isLoading) {
return (
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-28" />
</div>
</div>
);
}
return (
<div className="flex items-center gap-3">
<Avatar src={operator.avatar} />
<div>
<p className="font-medium">{operator.name}</p>
<p className="text-sm text-neutral-500">{operator.email}</p>
</div>
</div>
);
}Do: one region, mounted the whole time
SkeletonGroup renders on both sides of the transition — that is what makes the change in its text an announcement rather than an insertion. For the same reason, mount the group unconditionally: {isLoading && <SkeletonGroup … />} puts you straight back to the "don't".
// ✓ Do — the region is mounted (and empty) before the text arrives
<SkeletonGroup
loading={isLoading}
fallback={<Skeleton className="h-4 w-40" />}
>
<p>{operator.name}</p>
</SkeletonGroup>SkeletonGroup props
| Name | Type | Default | Description |
|---|---|---|---|
loading* | boolean | - | Whether the surface is still loading. Required, so the state is always wired |
fallback* | ReactNode | - | The skeletons rendered while loading — put layout classes here, not on the group |
children | ReactNode | - | The real content, rendered once loading is false |
label | string | 'Loading' | Screen-reader text held in the live region while loading |
loadedLabel | string | - | Optional text announced when loading finishes; omitted by default so a refetching dashboard stays quiet |
className | string | - | Classes for the root wrapper only; the group ships no layout of its own |
Accessibility
Skeletons are a status message in the WCAG 2.2 sense — a change of state the user has to learn about without moving focus (SC 4.1.3 Status Messages, AA). The kit splits that job in two, the same way Spinner does:
- Each
Skeletonisaria-hidden="true". Placeholder boxes carry no information, and there are always several, so exposing them just fills the accessibility tree with noise. SkeletonGroupowns the announcement. Onerole="status"region per surface, holdinglabel("Loading" by default) while loading andloadedLabel— nothing, unless you set it — afterwards.- The live region is a sibling of the
aria-busyhost, never a descendant.aria-busytells assistive tech to defer announcements for its own subtree, which is exactly the window the region needs to speak in. Collapsing both onto one element silences it. - The region must not unmount. A live region inserted together with its text is announced inconsistently; changing the text of a region that was already there is the reliable path.
- Skeletons animate with
animate-pulse, and keep animating underprefers-reduced-motion— deliberately. For an activity indicator the motion is the information, and WCAG 2.2 SC 2.2.2 exempts it on that basis; a frozen skeleton reads as a hung page. Add@import '@nim-ui/components/reduced-motion.css';to your stylesheet if your product would rather have it damped.
Known limitation: on initial mount the region already contains its text, so most screen readers will not fire a live announcement for it — the text is still in the accessibility tree and reachable in browse mode. SC 4.1.3's live case is the transition, which this shape does handle.
That last paragraph was a reading of the spec until it was measured. On NVDA 2026.1.1 with Firefox (Windows 11), 2026-08-03, reading backwards from the toggle on a freshly loaded page puts the region's text on the line immediately before it, with the placeholders in between silent; and toggling loading back on speaks that text ahead of the focused control's own new name, without moving focus. Both halves hold. Confirmed again on VoiceOver with Safari (macOS 26.5.2), 2026-08-03, where both halves hold the same way — with one reader difference: VoiceOver does not re-announce a focused control whose accessible name has changed, so the transition is heard as the region's text alone rather than as two utterances. Nothing above depends on the second one. JAWS was not run; its licence was declined.
Do not put focusable content (links, buttons, inputs) inside a Skeleton. It is aria-hidden, so anything focusable within it becomes reachable by keyboard but invisible to assistive tech.