Browse docs
Browse docs
Visual placeholder shown while data, images, or components are
loading. Three shapes — text, rectangle, circle — and three
animations — pulse (default), wave, none.
import { Skeleton } from '@dashforge/tw';
<Skeleton variant="text" width="200px" />
<Skeleton variant="rectangle" width="100%" height="120px" />
<Skeleton variant="circle" width="40px" />Use Skeleton when a real layout shape is known but the content inside is still being fetched. The skeleton occupies the same space the eventual content will occupy, so the page doesn't re-flow when data arrives.
Use a plain spinner instead when the layout itself is unknown (e.g. a modal that's still computing its size, or a page that's still routing).
variant | Default shape |
|---|---|
text (default) | h-[1em], w-full — rounded corners — for typical line-of-text placeholders |
rectangle | rounded-md, w-full, h-[100px] — for blocks (image placeholders, cards) |
circle | rounded-full, w-10 h-10 — height defaults to width automatically — for avatars |
import { Skeleton, Stack } from '@dashforge/tw';
<Stack gap={3} align="start">
<Skeleton variant="text" width="280px" />
<Skeleton variant="rectangle" width="280px" height="80px" />
<Skeleton variant="circle" width="48px" height="48px" />
</Stack>animation | Effect |
|---|---|
pulse (default) | Opacity 1.0 ↔ 0.5 every 2s. Tailwind's built-in animate-pulse. |
wave | A subtle gradient slides horizontally across the surface (MUI-style "wave"). |
none | No motion. Useful when an outer container is already animated, or to test reduced-motion behavior. |
import { Skeleton, Stack } from '@dashforge/tw';
<Stack gap={3} align="start">
<Skeleton variant="rectangle" width="280px" height="40px" animation="pulse" />
<Skeleton variant="rectangle" width="280px" height="40px" animation="wave" />
<Skeleton variant="rectangle" width="280px" height="40px" animation="none" />
</Stack>All animations are automatically suppressed when the user has set
prefers-reduced-motion: reduce in their OS — no extra config
needed.
Compose multiple <Skeleton> shapes to match the geometry of the real content. Each one is a simple <span aria-hidden="true"> with inline width / height styles, so they layer naturally inside any layout.
import { Card, CardContent, Skeleton, Stack } from '@dashforge/tw';
<Card>
<CardContent>
<Stack gap={3}>
<Stack direction="row" gap={3} align="center">
<Skeleton variant="circle" width="40px" height="40px" />
<Stack gap={1} style={{ flex: 1 }}>
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" width="40%" />
</Stack>
</Stack>
<Skeleton variant="rectangle" width="100%" height="120px" />
<Stack gap={1}>
<Skeleton variant="text" width="100%" />
<Skeleton variant="text" width="92%" />
<Skeleton variant="text" width="78%" />
</Stack>
</Stack>
</CardContent>
</Card>Skeleton follows the same dual-override pattern as every other
@dashforge/tw component — see the
Customization guide for the
full decision tree.
sx — outer wrapper<Skeleton variant="rectangle" sx="bg-amber-200 dark:bg-amber-900" />sx is a string of Tailwind utilities, appended after the variant
classes and merged via tailwind-merge. The example above
overrides the default bg-neutral-200 / dark:bg-neutral-800 color
with an amber tint.
slotProps.root<Skeleton
slotProps={{ root: { className: 'transition-opacity duration-500' } }}
/>Skeleton has a single slot (root) because it's an atomic
single-element component. For multi-element placeholders, compose
several <Skeleton>s.
Skeleton renders as a <span> with:
aria-hidden="true"role="presentation"Screen readers skip the skeleton entirely — they shouldn't
announce loading shapes. The surrounding container is responsible
for announcing loading state when relevant, via either
aria-busy="true" on the wrapper or an aria-live region with a
"Loading…" message.
Example pattern for a list:
<ul aria-busy={isLoading}>
{isLoading
? Array.from({ length: 5 }).map((_, i) => (
<li key={i}>
<Skeleton variant="text" width="80%" />
</li>
))
: items.map((item) => <li key={item.id}>{item.name}</li>)}
</ul>The aria-busy flag tells assistive tech "this region is loading,
don't try to read it yet". Once isLoading flips to false, the
content takes over and is announced normally.
If the user has prefers-reduced-motion: reduce set, both the
pulse and wave animations are automatically suppressed — the
skeleton becomes a static grey box. No work required from you.
This is part of the WCAG 2.3.3 (Animation from Interactions) compliance baseline for the entire library.
Configure <Skeleton> defaults application-wide.
import { patchTheme } from '@dashforge/tw-theme';
patchTheme({
components: {
Skeleton: {
defaults: { animation: 'wave' },
},
},
});Configurable axes (SkeletonVariantProps):
| Axis | Type | Notes |
|---|---|---|
variant | 'text' | 'rectangle' | 'circle' | Shape preset (rounded rectangle vs pill vs circle). |
animation | 'pulse' | 'wave' | 'none' | Loading animation style. Always motion-reduce safe. |
Non-visual axes (width, height) are per-instance — they carry the dimensions of the content being replaced, not a theme identity.
Precedence chain (lowest → highest):
defaultVariants from the internal skeletonVariants recipe (variant: 'text', animation: 'pulse').theme.components.Skeleton.defaults (application-wide).<Skeleton variant="circle" animation="wave" />) — wins over theme.sx — appended to the root and wins over conflicting classes via tailwind-merge.TypeScript: theme.components.Skeleton.defaults autocompletes to the two axes above.
Reactivity: useComponentDefaults('Skeleton') subscribes to the theme store — patchTheme re-renders every mounted instance inheriting the changed axis.
| Prop | Type | Default | Description |
|---|---|---|---|
animation | 'none' | 'pulse' | 'wave' | 'pulse' | Loading animation style. Always motion-reduce safe. |
height | string | — | CSS height (e.g. "1em", "120px").Defaults: text → 1em, rectangle → 100px, circle → matches width. |
slotProps | SkeletonSlotProps | — | Per-slot overrides. |
sx | string | — | Root-level Tailwind override (wins via tailwind-merge). |
variant | 'text' | 'rectangle' | 'circle' | 'text' | Shape preset — text (rounded rectangle sized for a line), rectangle (larger block), circle (avatar shape). |
width | string | — | CSS width (e.g. "200px", "100%", "60%").Defaults: text → 100%, rectangle → 100%, circle → 40px. |
<Skeleton> is nominally atomic — it renders a single <span aria-hidden> element — but exposes a slotProps.root handle for consistency with the compound-component surface:
| Slot | Purpose |
|---|---|
root | The single <span> element carrying shape + animation classes. |
Because there is only one slot, prefer sx for style overrides — it is shorter and equivalent (sx="..." = slotProps={{ root: { className: '...' } }}).
For compound placeholder layouts (avatar + text lines), compose multiple <Skeleton> instances inside a <Stack> or <Flex> — see the Composing a card placeholder section above.
sx vs slotProps decision tree