Loading…
Loading…
A placeholder UI that mimics the layout of content while it loads.
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:
When NOT to use a Skeleton:
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.
| 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 |
| 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.
Rather than individual skeleton shapes, most implementations compose skeletons into templates that mirror real component layouts:
These composed templates are matched 1:1 to the real component, so the transition from skeleton to content is seamless.
| 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 |
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.
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 |
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.
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. |
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:
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.
<!-- 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:
aria-hidden="true" — they are decorativearia-busy="true" on the container that will receive contentrole="status" announcement so screen readers know loading is happeningaria-busy="false" and remove the status message| 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. |
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.
| 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.
<!-- 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";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.