Overview
The Skeleton (also called a skeleton screen, placeholder, shimmer, or content loader) is a feedback component that displays a simplified, placeholder version of the UI while actual content is loading. Unlike a Spinner that says "something is happening," a skeleton says "here's roughly what you're about to see."
Skeleton screens are the gold standard for perceived performance. Research by Luke Wroblewski, Bill Scott, and others demonstrates that users perceive skeleton-loaded interfaces as significantly faster than spinner-loaded ones — even when the actual load time is identical. This works because skeletons provide spatial predictability: the user can see where content will appear, begin scanning the layout, and mentally prepare for the incoming data.
The concept was popularized by Facebook (now Meta) in 2014 for their mobile news feed, and has since been adopted by virtually every major platform: YouTube, LinkedIn, Slack, Figma, Notion, and dozens more.
When to use a Skeleton:
- Initial page loads where you know the content layout (lists, cards, profiles)
- Data fetching within known container shapes (tables, grids, feed items)
- Image placeholders while media loads
- Transitioning between content states (navigating between pages in an SPA)
- Any loading state longer than 1 second where you know the approximate layout
When NOT to use a Skeleton:
- For unknown layouts where you can't predict the content shape — use a Spinner
- For very short loads (< 300ms) — the skeleton will flash and feel janky
- For operations with measurable progress — use a Progress Bar
- For empty states — use an Empty State component
- When the skeleton layout differs drastically from actual content — the "bait and switch" effect is worse than a spinner
Preview skeleton shimmer animations in the Animation & Easing Tool. Generate skeleton pulse/shimmer gradients with the Color Palette Generator. Explore skeleton loading patterns in the Loader Generator.
Variants
Shape Variants
| Variant | Shape | Use Case |
|---|---|---|
| Text | Rounded rectangle, 14–16px tall, varying widths | Body text, labels, metadata |
| Heading | Rounded rectangle, 20–32px tall, 40–70% width | Page titles, card headings |
| Circle | Perfect circle, 32–64px diameter | Avatars, profile pictures, icons |
| Rectangle | Rounded rectangle with aspect ratio | Images, thumbnails, video previews |
| Card | Full card shape with internal skeleton elements | Product cards, article cards |
| Table Row | Horizontal set of rectangular skeletons | Data table loading |
| Paragraph | 3–4 text skeletons stacked, last line shorter | Content blocks, descriptions |
Animation Variants
| Variant | Animation | Performance | Use Case |
|---|---|---|---|
| Shimmer / Wave | A light gradient sweeps left-to-right across the skeleton. | Moderate (uses background-size animation or translateX). | Most common. Facebook, YouTube, LinkedIn. |
| Pulse | The skeleton fades between two opacity values. | Lightweight (only opacity animates). | Simple, low-overhead. Material Design default. |
| None / Static | No animation — solid gray shape. | Minimal. | When prefers-reduced-motion is active. |
The shimmer variant is more visually engaging but slightly more expensive to render. For pages with dozens of skeleton elements, the pulse variant may perform better. Preview both in the Animation & Easing Tool.
Composition Patterns
Rather than individual skeleton shapes, most implementations compose skeletons into templates that mirror real component layouts:
- Card Skeleton: Rectangle (image) + heading skeleton + 2 text skeletons + circle (avatar)
- List Item Skeleton: Circle (avatar) + heading + text
- Table Skeleton: Grid of rectangles matching column widths
- Article Skeleton: Large rectangle (hero) + heading + paragraph block
- Profile Skeleton: Large circle (avatar) + heading + text + text
These composed templates are matched 1:1 to the real component, so the transition from skeleton to content is seamless.
Properties
Skeleton Properties
| Property | Type | Default | Description |
|---|---|---|---|
variant | 'text' | 'heading' | 'circle' | 'rectangle' | 'custom' | 'text' | Shape of the skeleton element |
width | string | number | '100%' | Width of the skeleton. For text, use varying percentages (100%, 80%, 60%) for realism. |
height | string | number | '1em' | Height. For text, matches line height. For rectangles, set explicitly. |
borderRadius | string | number | '4px' | Corner rounding. Circles use '50%'. Preview with Border Radius Generator. |
animation | 'shimmer' | 'pulse' | 'none' | 'shimmer' | Animation type. Honors prefers-reduced-motion automatically. |
duration | number | 1500 | Animation cycle duration in milliseconds |
count | number | 1 | Renders multiple skeleton lines (for paragraph-like blocks) |
gap | string | number | '8px' | Gap between skeleton lines when count > 1 |
baseColor | string | 'var(--color-neutral-200)' | Background color of the skeleton shape |
highlightColor | string | 'var(--color-neutral-100)' | Color of the shimmer highlight |
isLoading | boolean | true | When false, renders children instead of skeleton. Enables inline usage. |
children | ReactNode | — | Content to show when isLoading is false |
Compound Component API
Some implementations (react-loading-skeleton, Chakra) offer a wrapper API:
<Skeleton isLoading={isLoading} fallback={<SkeletonCard />}>
<ActualCard data={data} />
</Skeleton>
This pattern cleanly separates loading and loaded states without conditional rendering in the parent.
Token Mappings
Design Token Mappings
Skeletons use a narrow set of tokens — primarily color and animation. See the Design Tokens Complete Guide for token architecture.
| Token Category | Token Example | Skeleton Usage |
|---|---|---|
| Color – Base | --color-neutral-200 | Skeleton background fill |
| Color – Highlight | --color-neutral-100 / --color-neutral-50 | Shimmer highlight color |
| Border Radius – Text | --radius-sm (4px) | Corner rounding for text shapes |
| Border Radius – Circle | 50% | Avatar/icon skeletons |
| Border Radius – Card | --radius-lg (12px) | Card skeleton containers |
| Spacing – Line Gap | --space-2 (8px) | Gap between text skeleton lines |
| Spacing – Paragraph Gap | --space-3 (12px) | Gap between skeleton sections |
| Animation Duration | --duration-skeleton (1.5s) | One cycle of shimmer or pulse. Preview in Animation & Easing Tool. |
| Animation Timing | ease-in-out | Shimmer easing function |
Dark Mode Considerations
Skeleton colors must invert for dark mode. The base should be slightly lighter than the page background, and the highlight slightly lighter still — just enough to be visible without being harsh:
/* Light mode */
--skeleton-base: var(--color-neutral-200); /* #e5e7eb */
--skeleton-highlight: var(--color-neutral-100); /* #f3f4f6 */
/* Dark mode */
--skeleton-base: var(--color-neutral-800); /* #1f2937 */
--skeleton-highlight: var(--color-neutral-700); /* #374151 */
Generate both light and dark skeleton palettes with the Color Palette Generator.
States
Skeleton States
Like Spinners, skeletons don't have interactive states (hover, focus, active). Their lifecycle is about visibility and transition:
| State | Behavior | Implementation |
|---|---|---|
| Loading (Active) | Skeleton is visible with animation running. | Render skeleton elements matching the expected content layout. |
| Transitioning | Content has loaded; skeleton morphs or fades into real content. | Crossfade: fade skeleton out while fading content in. Duration: 200–300ms. |
| Loaded (Hidden) | Content fully visible, skeleton removed from DOM. | Replace skeleton with actual content. Don't leave invisible skeletons in DOM. |
| Error | Data failed to load. | Replace skeleton with error UI or Empty State. Don't leave skeletons spinning forever. |
| Reduced Motion | Animation is paused or replaced with static gray shapes. | Use prefers-reduced-motion to disable shimmer/pulse. Static shapes are still useful as placeholders. |
Transition Best Practices
The transition from skeleton to content is the most important moment. Poor transitions (content popping in abruptly, layout shifts) can negate the perceived-performance benefits:
- Match dimensions exactly. The skeleton must be the same size as the real content. Any layout shift is jarring.
- Crossfade, don't swap. Fade the skeleton out (opacity 1→0) while fading content in (opacity 0→1). Duration: 200–300ms.
- Stagger content appearance. Load and reveal above-the-fold content first, then progressively reveal lower sections. This prevents a "flash of everything."
- Never show skeleton after content. If data loads faster than expected, skip the skeleton entirely (use a delay similar to the Spinner approach).
Accessibility
Accessibility — Skeleton
Skeletons are purely decorative placeholders — they have no interactive role and should be invisible to screen readers. The accessibility work is in communicating the loading state properly. Test skeleton-to-content contrast with the Contrast Checker.
ARIA Pattern
<!-- Region with skeleton loading -->
<div aria-busy="true" aria-label="Loading feed">
<!-- Skeleton elements — hidden from AT -->
<div class="skeleton skeleton-card" aria-hidden="true"></div>
<div class="skeleton skeleton-card" aria-hidden="true"></div>
<div class="skeleton skeleton-card" aria-hidden="true"></div>
<!-- Screen reader announcement -->
<div role="status" class="sr-only">Loading content…</div>
</div>
<!-- After loading completes -->
<div aria-busy="false">
<article><!-- real content --></article>
<article><!-- real content --></article>
</div>
Key points:
- Mark all skeleton elements as
aria-hidden="true"— they are decorative - Set
aria-busy="true"on the container that will receive content - Provide a
role="status"announcement so screen readers know loading is happening - When loading completes, set
aria-busy="false"and remove the status message
WCAG Success Criteria
| Criterion | Level | Requirement for Skeletons |
|---|---|---|
| 1.1.1 Non-text Content (A) | A | Skeleton shapes are decorative; use aria-hidden="true". The loading state must have a text alternative (role="status" announcement). |
| 1.3.1 Info and Relationships (A) | A | The relationship between loading state and content region must be conveyed via aria-busy on the container. |
| 1.4.11 Non-text Contrast (AA) | AA | Skeleton shapes should have at least 3:1 contrast against the page background so sighted users can perceive the placeholder. Use the Contrast Checker. |
| 2.2.1 Timing Adjustable (A) | A | If skeletons are tied to a timeout, provide an extension mechanism. |
| 2.3.1 Three Flashes (A) | A | Shimmer animations must not flash more than 3 times per second. A smooth left-to-right shimmer at 1.5s duration is well within limits. |
| 2.3.3 Animation from Interactions (AAA) | AAA | Respect prefers-reduced-motion — replace shimmer with static or gently pulsing placeholders. |
| 4.1.3 Status Messages (AA) | AA | The loading status must be communicated via role="status" so AT can announce it without focus. |
Layout Stability (CLS)
Skeletons directly impact Core Web Vitals' Cumulative Layout Shift (CLS). The skeleton must reserve the exact space that content will occupy. If your skeleton is 200px tall but the real content is 350px, you'll get a layout shift when content loads — defeating the purpose. Measure your CLS with Chrome DevTools or Lighthouse and ensure skeleton-to-content transitions produce zero shift.
Usage Guidelines
Do
- Match the skeleton to the real content layout precisely. Width, height, spacing, element count — all must mirror the actual UI. This is the fundamental rule.
- Use varying widths for text skeletons. Don't make every line the same width. Use 100%, 85%, 60% to mimic natural text line lengths.
- Keep skeletons simple. Gray shapes. No icons, no brand colors, no detail. The point is a silhouette, not a preview.
- Apply shimmer/pulse animation for visual feedback that loading is active. Static skeletons can be mistaken for broken UI.
- Combine with lazy loading. Show skeletons for below-the-fold content and load it as the user scrolls.
- Set a minimum display time (300ms) to prevent the skeleton from flashing for fast loads.
- Crossfade from skeleton to content. Don't let content "pop" in abruptly.
Don't
- Don't show skeletons forever. If loading fails, transition to an error state. Set a maximum display time (15–30 seconds) before showing an error or retry option.
- Don't use skeletons for unknown layouts. If you can't predict the content shape, use a Spinner.
- Don't over-detail skeletons. Showing exact icon shapes, text lengths, or brand elements makes the transition feel like content changing rather than content loading.
- Don't animate skeletons aggressively. Rapid or flashy shimmer is distracting and can fail WCAG 2.3.1.
- Don't show skeleton + spinner together. Pick one approach. Skeleton screens replace spinners; they don't complement them.
- Don't forget dark mode. Skeleton colors that work on white backgrounds disappear on dark backgrounds. Use semantic tokens.
- Don't cause layout shifts. The skeleton must be pixel-perfect in dimensions relative to the content it represents. Measure with CLS tools.
Content Loading Strategies
| Strategy | Approach | Best For |
|---|---|---|
| Component-level skeletons | Each component renders its own skeleton internally | Reusable components (cards, list items) |
| Page-level skeletons | A full-page skeleton template for each route | SPA route transitions, SSR hydration |
| Progressive skeletons | Load and reveal sections top-to-bottom | Long pages, infinite feeds |
| Inline skeletons | Skeleton as children with isLoading toggle | Granular control, per-field loading |
Explore loading patterns and prebuilt skeleton animations in the Loader Generator.
Code Snippets
<!-- Single text skeleton -->
<div class="skeleton skeleton--text" aria-hidden="true"></div>
<!-- Paragraph skeleton (3 lines) -->
<div class="skeleton-paragraph" aria-hidden="true">
<div class="skeleton skeleton--text" style="width: 100%"></div>
<div class="skeleton skeleton--text" style="width: 85%"></div>
<div class="skeleton skeleton--text" style="width: 60%"></div>
</div>
<!-- Card skeleton -->
<div class="skeleton-card" aria-hidden="true">
<div class="skeleton skeleton--rect" style="height: 200px"></div>
<div class="skeleton-card-body">
<div class="skeleton skeleton--text" style="width: 70%; height: 20px"></div>
<div class="skeleton skeleton--text" style="width: 100%"></div>
<div class="skeleton skeleton--text" style="width: 90%"></div>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 12px">
<div class="skeleton skeleton--circle" style="width: 32px; height: 32px"></div>
<div class="skeleton skeleton--text" style="width: 120px"></div>
</div>
</div>
</div>
<!-- Loading region with screen reader support -->
<div aria-busy="true">
<div class="skeleton-card" aria-hidden="true"><!-- ... --></div>
<div role="status" class="sr-only">Loading content…</div>
</div>
<style>
.skeleton {
background: var(--color-neutral-200);
border-radius: 4px;
position: relative;
overflow: hidden;
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
var(--color-neutral-100) 50%,
transparent 100%
);
animation: shimmer 1.5s ease-in-out infinite;
transform: translateX(-100%);
}
@keyframes shimmer {
100% { transform: translateX(100%); }
}
.skeleton--text {
height: 14px;
margin-bottom: 8px;
}
.skeleton--rect {
width: 100%;
border-radius: 8px 8px 0 0;
}
.skeleton--circle {
border-radius: 50%;
}
.skeleton-card {
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--color-neutral-200);
}
.skeleton-card-body {
padding: 16px;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
.skeleton::after { animation: none; }
}
</style>import { forwardRef } from "react";
interface SkeletonProps {
variant?: "text" | "heading" | "circle" | "rectangle";
width?: string | number;
height?: string | number;
borderRadius?: string | number;
animation?: "shimmer" | "pulse" | "none";
count?: number;
gap?: string | number;
baseColor?: string;
highlightColor?: string;
isLoading?: boolean;
className?: string;
children?: React.ReactNode;
}
export const Skeleton = forwardRef<HTMLDivElement, SkeletonProps>(
(
{
variant = "text",
width,
height,
borderRadius,
animation = "shimmer",
count = 1,
gap = 8,
baseColor = "var(--color-neutral-200)",
highlightColor = "var(--color-neutral-100)",
isLoading = true,
className,
children,
...props
},
ref
) => {
if (!isLoading && children) return <>{children}</>;
const variantStyles: Record<string, React.CSSProperties> = {
text: { height: height ?? 14, width: width ?? "100%", borderRadius: borderRadius ?? 4 },
heading: { height: height ?? 24, width: width ?? "60%", borderRadius: borderRadius ?? 4 },
circle: {
height: height ?? 40,
width: width ?? 40,
borderRadius: "50%",
},
rectangle: { height: height ?? 200, width: width ?? "100%", borderRadius: borderRadius ?? 8 },
};
const style = variantStyles[variant];
const elements = Array.from({ length: count }, (_, i) => (
<div
key={i}
aria-hidden="true"
className={className}
style={{
...style,
background: baseColor,
position: "relative" as const,
overflow: "hidden" as const,
...(i < count - 1 && variant === "text" ? {} : {}),
}}
{...props}
>
{animation === "shimmer" && (
<div
style={{
position: "absolute",
inset: 0,
background: `linear-gradient(90deg, transparent 0%, ${highlightColor} 50%, transparent 100%)`,
animation: "shimmer 1.5s ease-in-out infinite",
transform: "translateX(-100%)",
}}
/>
)}
</div>
));
return (
<div ref={ref} style={{ display: "flex", flexDirection: "column", gap }}>
{elements}
</div>
);
}
);
Skeleton.displayName = "Skeleton";Design Systems
Design System Implementations
Material Design 3 doesn't have a dedicated "skeleton" component in its spec — instead, it recommends using a container with a Placeholder layer that uses a subtle pulse animation (opacity oscillation between 0.04 and 0.12 on neutral tones). Material's approach is more restrained than the shimmer pattern — just a gentle breathing effect. This aligns with Material's philosophy of calm, non-distracting loading states.
Ant Design's Skeleton component is one of the most fully-featured implementations. It offers Skeleton.Avatar, Skeleton.Title, Skeleton.Paragraph, and Skeleton.Button sub-components that compose into realistic loading templates. The active prop enables shimmer animation, and the loading prop wraps real content — when loading becomes false, the skeleton seamlessly transitions to children. It also supports round shapes and custom paragraph row counts/widths.
react-loading-skeleton (an independent library, not a design system) is the de facto standard for React skeleton loading. It uses CSS @keyframes with background-position for the shimmer, provides <Skeleton count={5} /> for multi-line blocks, supports circle for avatars, and automatically adapts to parent width. Its SkeletonTheme provider allows global color customization.
Chakra UI provides a Skeleton component with startColor and endColor props for the animation gradient. It also offers SkeletonText (renders multiple lines with the last line shorter) and SkeletonCircle. The isLoaded prop triggers a smooth crossfade from skeleton to content — one of the cleanest transition implementations.
Shadcn/ui offers a bare-minimum Skeleton — just a <div> with animate-pulse (Tailwind's built-in pulse animation) and a muted background. No shimmer, no sub-components, no isLoading wrapper. You compose templates manually.
Bootstrap doesn't include skeletons in its core library. The community typically implements them using Bootstrap's existing utility classes or third-party plugins.
For creating shimmer and pulse animations with precise timing, use the Animation & Easing Tool. Generate skeleton color pairs (base + highlight) for both light and dark themes with the Color Palette Generator. Browse prebuilt skeleton patterns in the Loader Generator.