Skeleton
Animated loading placeholder. Mirrors the shape of the content that's coming — rectangles for images and headings, circles for avatars, stacked text lines for paragraphs — so the layout doesn't jump when real data arrives. Built-in shimmer animation, alternative pulse mode, and automatic respect for prefers-reduced-motion.
Usage
import { Skeleton } from '@bwo-ui/react';
<Skeleton width={200} height={20} />
<Skeleton variant="circle" width={40} height={40} />
<Skeleton variant="text" lines={3} />Variants
Three semantic shapes covering the common loading targets:
rect— the default. Block placeholder for images, headings, buttons, custom shapes.circle— perfectly round (border-radius: 50 %, aspect-ratio: 1). Built for avatars, icon badges, status dots.text— text-line placeholder with a slightly shorter height (0.9 em). Combine withlinesto render a paragraph.
variant=rectvariant=circlevariant=text<Skeleton width={140} height={56} /> {/* rect (default) */}
<Skeleton variant="circle" width={56} height={56} />
<Skeleton variant="text" lines={3} />Animation modes
Three animation modes — shimmer (default — a soft gradient sweeping left to right), pulse (opacity fade in / out), and none (static placeholder). Use none deliberately if you want a quiet loading state, or when an external animation is already carrying the loading feedback.
shimmerpulsenone<Skeleton animation="shimmer" height={14} /> {/* default */}
<Skeleton animation="pulse" height={14} />
<Skeleton animation="none" height={14} />Skeletons automatically disable their animation when the OS reports prefers-reduced-motion: reduce — you don't need to set animation="none" manually for that case.
Text lines
Pass lines (with variant="text") to render a stacked paragraph placeholder. The component wraps the lines in a flex column with an 8 px gap and shortens the final line to ~70 % width — the visual cue that mimics how natural prose wraps.
<Skeleton variant="text" /> {/* one line */}
<Skeleton variant="text" lines={3} /> {/* three stacked lines */}
<Skeleton variant="text" lines={5} /> {/* five stacked lines */}Sizing
width and height accept numbers (treated as pixels) or any CSS string ("100%", "14ch", "clamp(…)"). For custom corner shapes, pass radius as a CSS value — useful for matching the radius of the real element that'll replace the skeleton (chip buttons, rounded media, etc.).
<Skeleton width={160} height={20} />
<Skeleton width="100%" height={20} />
<Skeleton width="80%" height={20} />
<Skeleton width={200} height={80} radius="16px" />
<Skeleton variant="circle" width={64} height={64} />Recipes
List item
A typical row in a directory, mention list, or settings panel — avatar + name + sub text + an action button. The skeleton shapes mirror those slots one-to-one so the layout reads the same with or without data.
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Skeleton variant="circle" width={40} height={40} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
<Skeleton height={12} style={{ width: '45%' }} />
<Skeleton height={10} style={{ width: '65%' }} />
</div>
<Skeleton width={64} height={28} radius="14px" />
</div>Card
Card placeholders mirror the same anatomy as a real card — media block on top, title line, a couple of body lines, and a footer-pinned action. Wrap the skeletons in a real Card so the surface treatment (border, shadow, padding) is identical between loading and loaded states.
<Card>
<Skeleton height={120} radius="8px" style={{ marginBottom: 14 }} />
<Skeleton height={16} style={{ width: '60%', marginBottom: 8 }} />
<Skeleton variant="text" lines={2} />
<CardFooter>
<Skeleton width={88} height={32} radius="16px" />
</CardFooter>
</Card>Article paragraph
Long-form content (a blog post, an essay, a doc page) loads cleanest when the heading, byline, and paragraph all have skeletons. variant="text" with a high line count handles the body.
<Skeleton height={28} style={{ width: '70%' }} /> {/* heading */}
<Skeleton height={14} style={{ width: '40%' }} /> {/* byline */}
<Skeleton variant="text" lines={6} /> {/* body */}Toggle between loading and loaded
The real production pattern: render skeletons while data is fetching, swap to real components once it arrives. Wrap the loading region in aria-busy="true" so assistive tech can announce the state change.
function TeamCard({ data, loading }) {
return (
<Card>
<CardHeader>
{loading
? <Skeleton height={20} style={{ width: '45%' }} />
: <CardTitle>Team</CardTitle>}
</CardHeader>
<div aria-busy={loading} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{(loading ? Array.from({ length: 3 }) : data).map((person, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{loading
? <>
<Skeleton variant="circle" width={40} height={40} />
<div style={{ flex: 1 }}>
<Skeleton height={12} style={{ width: '45%' }} />
<Skeleton height={10} style={{ width: '60%' }} />
</div>
</>
: <>
<Avatar fallback={person.initials} />
<div>
<p>{person.name}</p>
<p>{person.role}</p>
</div>
</>}
</div>
))}
</div>
</Card>
);
}When to reach for a skeleton vs a spinner
- Skeleton — when the page shape is known in advance. The layout shouldn't jump when data arrives, so the loader mirrors the final composition.
- Spinner — when the response is fast (under ~ 300 ms — a skeleton would flash) or when the shape of the result isn't known until the request completes.
- Neither — when the operation is instant. A loading state that flashes for < 100 ms is noise; render the result directly.
Accessibility
- Wrap the loading region in an element with
aria-busy="true"while data is being fetched, then remove it on load. Screen readers will announce the state change. - Skeletons are decorative — they don't need labels of their own. The container's
aria-busyconveys all the meaning. If the surrounding context is unclear, pair the skeleton region with a visually-hidden<span role="status">Loading…</span>. - Animation respects
prefers-reduced-motion: reduceat the CSS layer — no additional code needed on your side. - Don't leave skeletons on the page indefinitely. If a request fails or stalls, surface an error or an empty state — a perpetually-loading skeleton reads as a broken page.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'rect' | 'circle' | 'text' | 'rect' | `rect` for blocks, `circle` for avatars, `text` for paragraph lines (combine with `lines`). |
animation | 'shimmer' | 'pulse' | 'none' | 'shimmer' | `shimmer` = sweeping gradient (default), `pulse` = opacity fade, `none` = static. All animations are disabled automatically under `prefers-reduced-motion: reduce`. |
width | number | string | — | Numbers are treated as pixels. Strings pass straight through — `"100%"`, `"14ch"`, `"clamp(...)"`. |
height | number | string | — | Same value semantics as `width`. For `text` variant, defaults to 0.9 em. |
lines | number | — | For `variant="text"` only — renders a stacked group of N lines. Last line is shortened to ~70 % width to read like the end of a paragraph. |
radius | string | — | Custom CSS border-radius override (e.g. `"12px"`, `"50%"`). Useful when the skeleton needs to match the radius of a non-standard element behind it. |
circle | boolean | — | Deprecated alias for `variant="circle"`. Kept for back-compat with 0.4.x. |
…rest | HTMLAttributes<HTMLSpanElement> | — | All native span attributes are forwarded — `style`, `aria-*`, `className`. |