Skip to content
txbrendan/ui

Skeleton

beta

A 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. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. 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

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>