Skip to content

Skeleton

A placeholder shaped like the content that is coming. It only works if it matches — a skeleton that is the wrong shape makes the page jump exactly when the user starts reading.

Also called Shimmer, Placeholder, Ghost Loading — in this system all of them are Skeleton.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

Matching the shape

Toggle between the skeleton and the real content in the playground above and watch for movement. If anything shifts, the skeleton is wrong.

Matched — nothing moves
api-gatewayLive

main · 4m ago

ALGH
18ms
Mismatched — the page jumps
api-gatewayLive

main · 4m ago

ALGH
18ms

Common shapes

Text lines end short because real paragraphs do not end flush. Avatars are circles. Buttons keep their radius. The closer the silhouette, the less the arrival is noticed.

Text block
Media object
Table rows
Stat tile

Skeleton or spinner?

Skeleton when the layout is known. Spinner when it is not, or when the loading region is smaller than a couple of lines.

Known layout → skeleton
Unknown result → spinner
Searching…

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Line
Heading
Paragraph
Avatar
Badge
Button
Thumbnail
No shimmerReduced motion
Card
Table row
nothing yet
DelayedAfter 200ms
api-gateway
Loaded

Anatomy

Every part, every measurement, and the reason it is that number.

Each block maps to a real element: the title bar is the width of a plausible title, the badge keeps the pill radius, the avatars stay circular.

  1. Block heightMatches the line box

    A 13px line with 1.6 line-height occupies about 21px. Use 12px for the bar so it reads as text without inflating the row.

  2. Block width60–90% of plausible

    Vary the widths. A stack of identical full-width bars reads as a chart, not as text.

  3. RadiusMatches the real element

    4px for text, full for avatars and badges, 8px for buttons. The silhouette is most of what sells it.

  4. Fill--ds-layer-active

    An alpha layer, so the same skeleton works on a card, a dialog and the page canvas without a second token.

  5. Shimmer1.6s linear, 200% sweep

    Slow and low-contrast. A fast, high-contrast shimmer is a photosensitivity risk and reads as urgency where none exists.

  6. Delay200ms before showing

    Below this the skeleton appears and vanishes within a frame or two, which looks like a rendering bug.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-layer-active—Skeleton fill
--ds-layer-hover—Shimmer highlight

Spacing

TokenValueUsed for
line gapBetween skeleton text lines

Radius

TokenValueUsed for
--radius-xsText bars
--radius-smDefault blocks
--radius-mdButtons and thumbnails
full—Avatars and badges

Motion

TokenValueUsed for
shimmerThe sweep
delayBefore the skeleton is shown at all

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightRadiusMin widthMax widthWhen to use
Caption line10px4px—60%Metadata and timestamps.
Body line12px4px—100%Paragraph text. Last line at ~62%.
Heading20px4px—70%Card and section titles.
Avatar20–40pxfull——Matches the real avatar size exactly.
Button36px8px72px—Keeps the control radius so the shape is recognisable.
Thumbnail56–96px8px——Match the aspect ratio, not just the height.

Do

Vary the line widthsReal paragraphs do not end flush. A last line at about 62% is what makes a block of bars read as text rather than as a bar chart.
const t = setTimeout(() => setShow(true), 200)
return () => clearTimeout(t)
Delay by 200msMost requests finish faster. Without the delay, every fast response produces a flash of grey that users read as a bug rather than as speed.
Render the same number of items you expectThree skeleton cards then eight real ones is a jump. Use the last known count, or the page size, so the container height barely changes.
<section aria-busy={loading}>
  <span aria-hidden className="skeleton" />
Put aria-busy on the region, not each blockOne announcement — "loading" — then the content. Twelve aria-labelled rectangles is noise that tells the user nothing they can act on.

Don't

Do not use a generic grey boxA single rectangle where a structured card will appear gives no shape information and guarantees a jump. It is a spinner with extra layout shift.
Do not shimmer aggressivelyA fast, high-contrast sweep is visually noisy, reads as urgency, and is a photosensitivity concern. 1.6s and a low-contrast highlight is the ceiling.
Do not skeleton the whole pageThe header, the navigation and the page title are already known. Skeletoning them makes the entire application appear to reload on every navigation.
skeleton 220ms → data 200ms → 420ms of theatre
Do not animate the skeleton inThe skeleton slides in, then the content fades in on top. That is two animations to show one piece of data, and it adds delay to something already late.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

4.1.3Status MessagesAA2.3.1Three Flashes or BelowA1.4.3Contrast (Minimum)AA

Contrast

  • Skeletons carry no information, so they are exempt from text contrast. They should still be visible — an invisible skeleton is an empty page.
  • The shimmer must not exceed a 3:1 luminance swing, or it counts as a flash for photosensitive users.
  • Keep the fill distinguishable from both the surface and the eventual content, so the transition to real data is obvious.

Keyboard

TabSkips skeletons entirely — they are never focusable.
Tab (after load)Focus must not be lost when the skeleton is replaced. Keep it on a stable ancestor.

Screen readers

  • A screen-reader user gets nothing from a skeleton. Announce the state change once: "Loading" on entry, and the result count on completion.
  • Do not put text inside a skeleton. "Loading…" written into a grey bar is announced along with everything else and adds nothing.
  • Keep the DOM structure stable. Replacing the whole subtree can move the screen reader’s virtual cursor back to the top of the page.

Focus & touch

  • Never move focus when the skeleton is replaced. If focus was on a filter input, it must still be there when the results arrive — otherwise the user is thrown back to the top of the page mid-task.
  • Skeletons are not interactive. Make sure the real content does not become tappable before it is fully rendered — a card that gains a link mid-animation causes mis-taps.
AttributeApplied toNotes
aria-hidden="true"Each skeleton blockThey are decorative. Individually announcing them is pure noise.
aria-busy="true"The loading regionOne flag for the whole region, removed when the content lands.
aria-live="polite"A status regionOptional. "Loading projects" then "12 projects loaded" is the useful pair.
role="status"The wrapperOnly when you want an announcement. A silent aria-busy region is usually enough.

Code

Example usage

tsx
1import { Skeleton, SkeletonText } from '@/ui/Feedback'23// The skeleton lives next to the component it mirrors, in the same file.4// That is the only reliable way to keep them in sync.5export function ProjectCard({ project }: { project?: Project }) {6  if (!project) return <ProjectCardSkeleton />7  return <Card>…</Card>8}910function ProjectCardSkeleton() {11  return (12    <Card padding="sm" aria-busy>13      <Skeleton className="h-3.5 w-28" />          {/* title  */}14      <Skeleton className="mt-3 h-3 w-20" />       {/* meta   */}15      <Skeleton className="mt-4 h-5 w-5" rounded="full" />16    </Card>17  )18}1920// Delay so fast responses never flash21function useDelayed(active: boolean, ms = 200) {22  const [on, setOn] = useState(false)23  useEffect(() => {24    if (!active) return setOn(false)25    const t = setTimeout(() => setOn(true), ms)26    return () => clearTimeout(t)27  }, [active, ms])28  return on29}3031// Render the count you expect, not an arbitrary three32<section aria-busy={loading}>33  {loading34    ? Array.from({ length: lastCount ?? pageSize }, (_, i) => <ProjectCardSkeleton key={i} />)35    : projects.map((p) => <ProjectCard key={p.id} project={p} />)}36</section>

CSS

css
.ds-skeleton {
  display: block;
  background: var(--ds-layer-active);   /* alpha — works on any surface */
  border-radius: var(--radius-sm);
  position: relative;
  overflow: hidden;
}

/* A slow, low-contrast sweep. Fast or high-contrast shimmer is a
   photosensitivity concern and reads as urgency. */
.ds-skeleton::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(
    90deg,
    transparent 0%,
    var(--ds-layer-hover) 50%,
    transparent 100%
  );
  background-size: 200% 100%;
  animation: shimmer 1.6s linear infinite;
}

@keyframes shimmer {
  from { background-position: -180% 0 }
  to   { background-position:  180% 0 }
}

/* Text lines: vary the widths, and end short */
.ds-skeleton--text { block-size: 12px; border-radius: var(--radius-xs); }
.ds-skeleton--text:last-child { inline-size: 62%; }

@media (prefers-reduced-motion: reduce) {
  .ds-skeleton::after { animation: none; }
}

Component API

Skeleton

PropTypeDefaultDescription
classNamestring—Size it with utilities. Match the real element’s box exactly.
rounded'sm' | 'md' | 'lg' | 'full''md'Match the radius of what it stands in for.
animatebooleantrueDisable for very large grids where fifty shimmers is too much.

SkeletonText

PropTypeDefaultDescription
linesnumber3Number of bars. The last one renders at 62% width.

Notes

Professional tips

  • Keep the skeleton in the same file as the component it mirrors and export both. Skeletons in a separate file drift out of sync within a month.
  • Cache the last known item count and render that many skeletons. It is the difference between a stable container and one that resizes twice per load.
  • For infinite scroll, render two or three skeleton rows at the bottom rather than a spinner. It doubles as an affordance that more is coming.
  • A skeleton for a chart should be the chart’s bounding box with faint axis lines, not a solid block. The silhouette is the information.

Performance

  • Fifty shimmering skeletons is fifty simultaneous background-position animations. Above about twenty, animate a single overlay across the group or disable the shimmer.
  • Skeletons must not trigger the same data fetch as the real component. Keep them purely presentational with no hooks.
  • Reserve space with aspect-ratio rather than a fixed height where the content is responsive, so the reservation stays correct at every width.
  • content-visibility: auto on offscreen skeleton rows skips their layout and their animation entirely.

Common mistakes

  • Rendering the skeleton immediately, so every fast request produces a flash of grey.
  • A skeleton whose dimensions do not match the content, which turns a loading state into a layout shift.
  • Leaving the shimmer running under prefers-reduced-motion.
  • Announcing each skeleton block to screen readers instead of putting one aria-busy on the region.
  • Replacing the whole subtree on load, which resets scroll position and moves the screen reader’s cursor.

Real-world recommendations

  • Measure cumulative layout shift before and after adding skeletons. If CLS did not improve, the skeletons are the wrong size and are adding work for nothing.
  • Skeletons make a page feel faster than a spinner at the same latency, but they cannot rescue a genuinely slow endpoint. Fix the p95 first.
  • For a returning user, cached data plus a background refresh beats any skeleton. Show the stale content immediately and update it in place.
  • Screenshot the skeleton and the loaded state and flip between them. Anything that moves is a bug you can fix in a minute and would otherwise never notice.