Skeleton
betaA pulsing placeholder shape that holds the place of content while it loads. Hidden from screen readers.
Preview
shapes
in a card
Pro plan
For teams that ship every week.
$24 per month
Loading plan
on muted
list
Loading people
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/skeleton
Use it for
- Holding the place of content that is loading, at the same size and shape, so the page does not jump when it arrives.
- Size and shape it with className: h-4 w-48 for a line of text, size-10 rounded-full for an avatar.
- Put aria-busy="true" on the region that is loading. Say it in words in a role="status" element that stays on the page, outside that region.
Avoid it for
- Wrapping real content. A skeleton takes no children. Render it in place of the content, not around it.
- An action in progress, like saving. Use loading on the Button.
- Loads that finish in well under a second. The skeleton only flashes. Show nothing until the content arrives.
- Layouts you cannot predict. If the shape will not match, a short status message is clearer.
Props
- className
Size and shape. Later classes win, so className='rounded-full' replaces the radius and 'animate-none' stops the pulse.
string
- aria-hidden
Set for you, so screen readers skip the shape.
boolean · default true
- children
Not allowed. TypeScript rejects content inside a skeleton, because aria-hidden would hide it.
never
| Prop | Type | Default | What it does |
|---|---|---|---|
| className | string | - | Size and shape. Later classes win, so className='rounded-full' replaces the radius and 'animate-none' stops the pulse. |
| aria-hidden | boolean | true | Set for you, so screen readers skip the shape. |
| children | never | - | Not allowed. TypeScript rejects content inside a skeleton, because aria-hidden would hide it. |
Accessibility
- Renders a <span> with aria-hidden, so screen readers skip it. Its shape only means something to people who can see it.
- Tell screen readers in words. Keep one role="status" element on the page and change its text: "Loading orders" while the skeletons show, then "Orders loaded".
- Put that status element outside the loading region. Screen readers may wait to read changes inside a region marked aria-busy, and some miss a status region that arrives together with its text.
- Add aria-busy="true" to the region that is loading, and remove it when the content arrives.
- It is a <span> with display block, so it is valid inside a paragraph, a heading, or a button.
- It pulses at --motion-pulse-duration. The theme sets that to 0s for motion off and for reduced motion, so the shape stays still then.
- The fill is the theme's --color-skeleton, the foreground at 10%, so the shape shows on the page, on cards, and on muted panels. A placeholder is not text, so the theme does not hold it to a contrast minimum.
Examples
A line of text
<Skeleton className="h-4 w-48" />A profile row, with a status that stays on the page
function Profile({ user }: { user: { name: string; role: string; avatar: string } | null }) {
return (
<>
<div aria-busy={user === null} className="flex items-center gap-3">
{user === null ? (
<>
<Skeleton className="size-10 rounded-full" />
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</>
) : (
<>
<img src={user.avatar} alt="" className="size-10 rounded-full" />
<div>
<p className="font-medium">{user.name}</p>
<p className="text-sm text-muted-foreground">{user.role}</p>
</div>
</>
)}
</div>
<p role="status" className="sr-only">
{user === null ? "Loading profile" : "Profile loaded"}
</p>
</>
);
}A card while it loads (needs @txbrendan/card)
import { Card, CardContent, CardHeader } from "@/components/ui/card";
// Pair it with a role="status" element outside the card, as in the profile row.
<Card aria-busy="true">
<CardHeader>
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-4 w-3/4" />
</CardHeader>
<CardContent>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>