Theme studio

Design a theme, preview it live, then export it. Saved in this browser.

Quick picks #10b981
Generated scale
50100200300400500600700800900
Some text is below AA

Loading without layout shift

Reserve the final size while data loads, so nothing jumps when it arrives and screen readers know the region is busy.

The problem

A spinner is small. The content that replaces it is not. When the list arrives, everything below it moves, the button the person was about to press slides away, and Cumulative Layout Shift goes up. Screen reader users get no signal at all that something is loading.

When to use it

Use it for

  • Content whose shape you know before it loads: lists, cards, table rows, profile headers.
  • Loads that usually take between 300 ms and a few seconds.

Not for

  • Loads under about 300 ms: show nothing, or the skeleton flashes.
  • Long jobs with known progress, like an upload: use ProgressBar with a value.
  • Actions on a button: use the button's loading state, not a skeleton.

Anatomy

  1. Busy regionThe container that will hold the content. It carries aria-busy while loading.
  2. SkeletonPlaceholder shapes the same size as the final rows, so the height never changes.
  3. Live textAn optional polite message, such as “12 people loaded”, when loading ends.

How to build it

  1. Pick the Skeleton type that matches the final content: text, avatar-row, card or table-row.
  2. Render as many skeleton rows as you expect results, or the page size if you don't know.
  3. Set aria-busy on the region while loading, and remove it when the content is in.
  4. Wait about 300 ms before showing the skeleton, so fast loads never flash.
  5. Keep the region's min-height until the real content has rendered.
app.tsx
import { Skeleton } from "@nexera-ui/react";

export function TeamList({ people, isLoading }: Props) {
  return (
    <section aria-busy={isLoading} aria-live="polite" className="grid min-h-[96px] gap-2">
      {isLoading
        ? Array.from({ length: 4 }, (_, i) => <Skeleton key={i} type="avatar-row" />)
        : people.map((p) => <PersonRow key={p.id} person={p} />)}
    </section>
  );
}

Accessibility

  • aria-busy tells assistive technology the region is changing; screen readers wait before reading it.
  • Skeletons are decorative and hidden from the accessibility tree. Don't give them labels.
  • Announce the result politely when loading ends, not every placeholder.
  • Skeleton shimmer stops under prefers-reduced-motion.

Do and don't

DoReserve the final size with Skeleton and set aria-busy on the region that loads.

Don'tSwap a spinner for content of a different height.

DoMatch the skeleton to the layout: avatar rows for people, table rows for tables.

Don'tUse one generic grey block for every kind of content.

DoDelay the skeleton by about 300 ms so fast responses render straight away.

Don'tFlash a skeleton for 50 ms on every navigation.