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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
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.
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.
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.
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.
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.
- 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.
- Block width60–90% of plausible
Vary the widths. A stack of identical full-width bars reads as a chart, not as text.
- RadiusMatches the real element
4px for text, full for avatars and badges, 8px for buttons. The silhouette is most of what sells it.
- 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.
- 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.
- Delay200ms before showing
Below this the skeleton appears and vanishes within a frame or two, which looks like a rendering bug.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-layer-active | — | Skeleton fill |
| --ds-layer-hover | — | Shimmer highlight |
Spacing
| Token | Value | Used for |
|---|---|---|
| line gap | Between skeleton text lines |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-xs | Text bars | |
| --radius-sm | Default blocks | |
| --radius-md | Buttons and thumbnails | |
| full | — | Avatars and badges |
Motion
| Token | Value | Used for |
|---|---|---|
| shimmer | The sweep | |
| delay | Before the skeleton is shown at all |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Radius | Min width | Max width | When to use |
|---|---|---|---|---|---|
| Caption line | 10px | 4px | — | 60% | Metadata and timestamps. |
| Body line | 12px | 4px | — | 100% | Paragraph text. Last line at ~62%. |
| Heading | 20px | 4px | — | 70% | Card and section titles. |
| Avatar | 20–40px | full | — | — | Matches the real avatar size exactly. |
| Button | 36px | 8px | 72px | — | Keeps the control radius so the shape is recognisable. |
| Thumbnail | 56–96px | 8px | — | — | Match the aspect ratio, not just the height. |
const t = setTimeout(() => setShow(true), 200)
return () => clearTimeout(t)<section aria-busy={loading}>
<span aria-hidden className="skeleton" />Not a checklist to run at the end. These are the requirements the component was built from.
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
| Tab | Skips 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.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-hidden="true" | Each skeleton block | They are decorative. Individually announcing them is pure noise. |
| aria-busy="true" | The loading region | One flag for the whole region, removed when the content lands. |
| aria-live="polite" | A status region | Optional. "Loading projects" then "12 projects loaded" is the useful pair. |
| role="status" | The wrapper | Only when you want an announcement. A silent aria-busy region is usually enough. |
Example usage
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
.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
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | 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. |
| animate | boolean | true | Disable for very large grids where fifty shimmers is too much. |
SkeletonText
| Prop | Type | Default | Description |
|---|---|---|---|
| lines | number | 3 | Number of bars. The last one renders at 62% width. |
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.