Skeleton
The canonical loading-placeholder primitive. <Skeleton /> claims layout space for content
that isn't ready yet, and animates lightly so users can tell the page is alive without
demanding their attention.
Overview — avatar + text, card, and list-item patterns
Three exports ship on a single shared recipe:
<Skeleton />— the block primitive; any width × height × radius.<SkeletonText />— multi-line text placeholder; auto-shortens the last line.<SkeletonAvatar />— circular placeholder sized 1:1 with<Avatar />.
Why this exists
Pages that re-flow when data arrives feel broken. Skeletons solve two jobs:
- Reserve space. The layout doesn't jump when content lands —
width/heighton the placeholder match the eventual content's box. - Signal liveness. A subtle shimmer or pulse tells the user "we're still loading"
without spinning at them. Both animations halt under
prefers-reduced-motion.
Anatomy
┌──────────────────────────────────────────────────────┐
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ← <Skeleton>
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ← <SkeletonText>
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ (shortened) │
└──────────────────────────────────────────────────────┘
● ← <SkeletonAvatar> (rounded-full, size matches <Avatar>)┌──────────────────────────────────────────────────────┐
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ← <Skeleton>
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ← <SkeletonText>
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ (shortened) │
└──────────────────────────────────────────────────────┘
● ← <SkeletonAvatar> (rounded-full, size matches <Avatar>)Variants
| Variant | Background | When to reach for it |
|---|---|---|
solid | neutral bg-subtle | Default. The "every-page" loading state. |
soft | <color>-subtle | Brand-immersive splashes / status-aware skeletons (e.g. danger). |
Animations
| Animation | Behavior | When |
|---|---|---|
shimmer | Left-to-right gradient sweep (1.6s loop, linear) | Default. The modern UX. |
pulse | Opacity oscillation (1.8s loop) | When the shimmer highlight can't |
| contrast against the surface. | ||
none | Static low-opacity block | Reduced-motion-by-default pages, |
| nested compositions, lazy loads. |
All three respect prefers-reduced-motion: the animation halts and a 70% opacity
fallback paints in its place. ARIA semantics are unchanged.
Examples
Default — a single block placeholder
SkeletonText — multi-line, auto-shortened last line
SkeletonAvatar — every Avatar-aligned size
solid vs soft
shimmer / pulse / none
Roundedness — none / sm / md / lg / full
Every role color × soft variant
Canonical 'card while loading' composition
Avatar + text-line combo (common UI pattern)
Full-page skeleton mockup
Width / height combos
Props
<Skeleton />
| Prop | Type | Default | Notes |
|---|---|---|---|
width | string | number | — | Number coerces to px. String passes through. |
height | string | number | — | Number coerces to px. |
rounded | 'none' | 'sm' | 'md' | 'lg' | 'full' | 'md' | Maps to the theme's radius scale (Katana/Tetsu/Origami flow through). |
animation | 'shimmer' | 'pulse' | 'none' | 'shimmer' | All three halt under prefers-reduced-motion. |
variant | 'solid' | 'soft' | 'solid' | Responsive via the { base, md } shape. |
color | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'neutral' | Only visible under variant="soft". |
className | string | — | Merged via tailwind-merge. Last-wins. |
sx | Sx | — | Theme-aware inline style. |
<SkeletonText />
Extends <Skeleton /> (minus width) with:
| Prop | Type | Default | Notes |
|---|---|---|---|
lines | number | 3 | Coerced to Math.max(1, Math.floor(lines)). |
spacing | 'sm' | 'md' | 'lg' | 'md' | Vertical gap between lines. |
lastLineWidth | string | number | '60%' | Last line shortens for typographic realism. |
height | string | number | 14 | Per-line height; matches a body font line-height by default. |
<SkeletonAvatar />
Extends <Skeleton /> (minus width / height / rounded) with:
| Prop | Type | Default | Notes |
|---|---|---|---|
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | number | 'md' | Mirrors <Avatar size> 1:1. Numeric = px. |
Accessibility
- Root announces once.
<Skeleton />and<SkeletonText />carryrole="status"andaria-label="Loading"so screen readers report the loading state. Overridearia-labelto localize or to disambiguate ("Loading user profile", "Loading messages", …). - Nested lines are silent. Each
<Skeleton />line inside<SkeletonText />isaria-hidden="true"— only the wrapper announces. The same escape hatch is available on a bare<Skeleton aria-hidden="true" />when you want to nest one inside a custom wrapper that owns the announcement. - Reduced motion.
prefers-reduced-motion: reducehalts the shimmer / pulse animation via themotion-reduce:Tailwind variant and drops opacity to 70% so the placeholder is still visually distinct from the surface. - axe-core. Zero violations across the
variant × color × animationmatrix. - Replace, don't overlay. When real data lands, swap the
<Skeleton />for the actual content — screen readers want the announcement transition (status → content). Don't paint the content under the skeleton.
Theming
defineTheme({
components: {
Skeleton: {
defaultProps: { animation: 'pulse', rounded: 'sm' },
styleOverrides: {
root: 'bg-bg-paper', // tighter contrast against page background
},
},
},
});defineTheme({
components: {
Skeleton: {
defaultProps: { animation: 'pulse', rounded: 'sm' },
styleOverrides: {
root: 'bg-bg-paper', // tighter contrast against page background
},
},
},
});You can also retune the shimmer palette per theme by setting the two CSS variables the
shimmer gradient references — set them anywhere from :root down to an element-level
style:
:root {
--sds-skeleton-base: rgba(0, 0, 0, 0.06);
--sds-skeleton-highlight: rgba(255, 255, 255, 0.18);
}
:root[data-mode='dark'] {
--sds-skeleton-base: rgba(255, 255, 255, 0.06);
--sds-skeleton-highlight: rgba(255, 255, 255, 0.16);
}:root {
--sds-skeleton-base: rgba(0, 0, 0, 0.06);
--sds-skeleton-highlight: rgba(255, 255, 255, 0.18);
}
:root[data-mode='dark'] {
--sds-skeleton-base: rgba(255, 255, 255, 0.06);
--sds-skeleton-highlight: rgba(255, 255, 255, 0.16);
}Both have sensible RGB fallbacks baked into the gradient string, so the shimmer works out of the box on every theme — these overrides are pure refinement.