NimUI
ComponentsFeedback

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

Text Skeleton
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

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

Profile 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

NameTypeDefaultDescription
classNamestring-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.

SkeletonGroup — Toggle the loading state; the live region stays mounted across the transition
Loading operator
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.

loadedLabel — The same toggle, announcing when the load finishes rather than falling silent
Loading operator
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

NameTypeDefaultDescription
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
childrenReactNode-The real content, rendered once loading is false
labelstring'Loading'Screen-reader text held in the live region while loading
loadedLabelstring-Optional text announced when loading finishes; omitted by default so a refetching dashboard stays quiet
classNamestring-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 Skeleton is aria-hidden="true". Placeholder boxes carry no information, and there are always several, so exposing them just fills the accessibility tree with noise.
  • SkeletonGroup owns the announcement. One role="status" region per surface, holding label ("Loading" by default) while loading and loadedLabel — nothing, unless you set it — afterwards.
  • The live region is a sibling of the aria-busy host, never a descendant. aria-busy tells 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 under prefers-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.

  • Spinner — Animated loading indicator
  • Progress — Determinate progress bar
  • DataTable — Loading a table: cells take the placeholders, the table owns the announcement

On this page