Building Effective React Skeleton Loading UIs: Patterns, Code, and A11y Tips
Build accessible, performant skeleton loading UIs in React with patterns, Suspense, CSS shimmer, and testing tips.
Image used for representation purposes only.
Overview
Skeleton loading UIs replace blank screens and spinners with lightweight placeholders that mirror the final layout. Done well, they reduce perceived wait time, prevent layout shift, and create a calmer, more trustworthy experience while data loads.
This guide shows how to design, implement, and ship accessible, performant skeletons in React—covering patterns for client-side fetching, Suspense, lists, theming, and testing.
Why skeletons beat spinners
- Maintain spatial context by reserving the exact footprint of incoming content.
- Reduce perceived latency with subtle motion that suggests progress.
- Avoid jarring content jumps (CLS) by stabilizing layout early.
- Communicate structure, not fake content—users can predict what’s coming.
Use skeletons when latency is likely noticeable (roughly >300–500 ms) or when large areas of the page depend on async data. For micro-latency interactions (<200 ms), prefer optimistic UI or no loading UI at all.
Design principles that scale
- Mirror the final layout: shapes, spacing, and aspect ratios should match real content.
- Be understated: low-contrast base + gentle highlight; avoid high-frequency shimmer or large translation distances.
- Animate responsibly: respect reduced motion settings and pause shimmer if
prefers-reduced-motionis enabled. - Dark mode ready: provide tokens for base and highlight colors per theme.
- Reserve space deterministically: set explicit width/height/aspect-ratio to prevent shift.
- Keep it decorative: skeletons should be hidden from screen readers.
From-scratch CSS you can drop in
Below is a minimal, themeable shimmer with reduced-motion handling.
:root {
--sk-base: hsl(210 15% 88%);
--sk-highlight: hsl(210 15% 96%);
}
[data-theme="dark"] {
--sk-base: hsl(215 10% 22%);
--sk-highlight: hsl(215 10% 28%);
}
.skeleton {
position: relative;
display: inline-block;
background-color: var(--sk-base);
overflow: hidden;
border-radius: 8px;
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255 255 255 / 0.55) 50%,
transparent 100%
);
transform: translateX(-100%);
animation: sk-shimmer 1.4s ease-in-out infinite;
mix-blend-mode: overlay;
}
@keyframes sk-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* Variants */
.skeleton--circle { border-radius: 999px; }
.skeleton--text { height: 1em; border-radius: 0.25em; }
.skeleton--muted { background-color: color-mix(in oklab, var(--sk-base), black 6%); }
/* Respect accessibility preferences */
@media (prefers-reduced-motion: reduce) {
.skeleton::after { animation: none; }
}
A reusable React Skeleton component
Create a small, composable primitive for precise sizing and variants.
import React from 'react';
type SkeletonProps = {
width?: number | string;
height?: number | string;
circle?: boolean;
variant?: 'text' | 'rect' | 'avatar';
style?: React.CSSProperties;
className?: string;
};
export function Skeleton({
width,
height,
circle,
variant = 'rect',
style,
className = ''
}: SkeletonProps) {
const classes = [
'skeleton',
variant === 'text' && 'skeleton--text',
(circle || variant === 'avatar') && 'skeleton--circle',
className
].filter(Boolean).join(' ');
return (
<span
aria-hidden="true"
role="presentation"
className={classes}
style={{ width, height, ...style }}
/>
);
}
Composing meaningful placeholders
Mirror the final card/list layout with semantic wrappers hidden from AT.
function CardSkeleton() {
return (
<article aria-hidden="true" className="card">
<Skeleton variant="avatar" width={48} height={48} />
<div style={{ flex: 1 }}>
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" width="40%" style={{ marginTop: 8 }} />
</div>
<Skeleton width={88} height={32} />
</article>
);
}
function ListSkeleton({ rows = 6 }: { rows?: number }) {
return (
<ul aria-hidden="true" className="list">
{Array.from({ length: rows }).map((_, i) => (
<li key={i} className="list-row">
<Skeleton variant="avatar" width={40} height={40} />
<div style={{ flex: 1 }}>
<Skeleton variant="text" width="70%" />
<Skeleton variant="text" width="40%" style={{ marginTop: 6 }} />
</div>
</li>
))}
</ul>
);
}
Tip: keep DOM minimal. Prefer one skeleton element per visual chunk; avoid dozens of nested divs.
Integrating with client-side fetching
Skeletons pair naturally with data-fetching libraries.
Example with React Query:
import { useQuery } from '@tanstack/react-query';
function PostList() {
const { data, isLoading, isError } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json())
});
if (isLoading) return <ListSkeleton rows={8} />;
if (isError) return <p role="alert">Couldn’t load posts.</p>;
return <RealPostList posts={data} />;
}
Progressive lists: render a fixed number of skeleton rows matching the expected results per page so the container height is stable.
Suspense boundaries and streaming
If your stack uses React Suspense for data fetching and/or streaming SSR, wrap sections with boundaries and provide skeleton fallbacks.
import { Suspense } from 'react';
export default function Dashboard() {
return (
<>
<Header />
<main>
<Suspense fallback={<CardSkeleton />}>
<KPISection />
</Suspense>
<Suspense fallback={<ListSkeleton rows={10} />}>
<RecentActivity />
</Suspense>
</main>
</>
);
}
Guidelines:
- Boundary per cohesive section; avoid wrapping the entire page in a single Suspense.
- Keep fallbacks visually similar to the resolved UI.
- With streaming, send shell HTML quickly and progressively reveal sections as data resolves.
Accessibility: do no harm
Skeletons are presentational; they should not pollute the accessibility tree or trap focus.
- Hide from AT:
aria-hidden="true"androle="presentation"on skeleton primitives and containers. - Don’t render placeholder text nodes like “Loading title…”; screen readers might read them.
- Use a single live region elsewhere to announce when content is ready if necessary.
// Example live region for major screen updates
function LiveRegion({ message }: { message: string }) {
return (
<div aria-live="polite" role="status" style={{ position: 'absolute', left: -9999 }}>
{message}
</div>
);
}
- Motion sensitivity: disable shimmer for
prefers-reduced-motion: reduce. - Color contrast: skeletons are decorative, but ensure sufficient contrast between base and page background for visibility in both themes.
Performance and stability
- Reserve space: set
width,height, oraspect-ratioon skeletons to prevent shifts. - Keep it cheap: CSS gradients + one pseudo-element cost less than stacking elements or using GIFs.
- Contain work: for large lists, consider
content-visibility: auto;or virtualize the list itself. - Avoid overdraw: small shimmer overlays and subtle gradients are kinder to GPUs.
- Timeout strategy: if loading exceeds a threshold, show a friendly error or retry instead of endless skeletons.
.long-list-skeleton {
content-visibility: auto;
contain-intrinsic-size: 600px; /* approximate height to reserve */
}
Theming and design tokens
Centralize your palette and radii so skeletons adapt to brand and dark mode automatically.
:root {
--radius-sm: 6px;
--radius-md: 10px;
--sk-base: oklch(0.92 0.02 250);
--sk-highlight: oklch(0.97 0.01 250);
}
[data-theme="dark"] {
--sk-base: oklch(0.28 0.02 250);
--sk-highlight: oklch(0.34 0.02 250);
}
.skeleton { border-radius: var(--radius-md); background: var(--sk-base); }
Patterns for common components
- Avatars: circular 32–48 px; reserve the exact size used in production.
- Text blocks: multiple
.skeleton--textlines with varied widths (e.g., 80%, 60%, 40%) to suggest natural rag. - Media: use
aspect-ratio(16/9, 1, 4/3) to hold space for images/video. - Buttons and inputs: reflect dimensions so the surrounding layout doesn’t jump when they load.
<Skeleton width="100%" style={{ aspectRatio: '16/9' }} />
Coordinating transitions with useTransition
For refreshes of already-visible content, prefer useTransition to keep the old UI on screen while the new data loads. This often eliminates the need for skeletons on re-fetch.
import { useTransition } from 'react';
function RefreshableList() {
const [isPending, startTransition] = useTransition();
const { data, refetch } = usePosts();
return (
<>
<button disabled={isPending} onClick={() => startTransition(() => refetch())}>
{isPending ? 'Refreshing…' : 'Refresh'}
</button>
<RealPostList posts={data} dim={isPending} />
</>
);
}
Dim or subtly blur content during background refresh rather than swapping it for skeletons.
Testing your loading states
Use Testing Library to assert that skeletons appear while data loads and disappear once content resolves.
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
it('shows skeletons, then content', async () => {
render(<PostList />);
expect(screen.getByTestId('post-list-skeleton')).toBeInTheDocument();
await waitForElementToBeRemoved(() => screen.getByTestId('post-list-skeleton'));
expect(screen.getByRole('list')).toBeInTheDocument();
});
Implementation tip: add data-testid to your high-level skeleton container (not to every placeholder piece) to keep tests resilient.
When not to use skeletons
- Ultra-fast responses (<200 ms) where flashing a placeholder is distracting.
- Tiny inline updates (e.g., a like count) where optimistic UI works better.
- Low-powered devices when animation causes jank—prefer static placeholders.
Production checklist
- Layout parity: skeleton sizes match final components 1:1.
- Motion accessible: shimmer disabled when
prefers-reduced-motion. - Hidden from AT:
aria-hidden/role="presentation"set correctly. - Stable containers: explicit size/aspect ratio; zero layout shift when data lands.
- Performance budget: minimal DOM, no heavy shadows/filters, content-visibility for long lists.
- Error path: friendly fallback if loading runs long or fails.
- Theming: respects light/dark and brand tokens.
Recommended building blocks
- DIY CSS/React primitive (shown above) for full control, or leverage a component library’s Skeleton to accelerate delivery.
- SVG-based loaders can be useful for complex shapes or highly scalable placeholders.
Wrap-up
Skeletons are more than eye candy—they’re a contract that the final layout will occupy the same space, arrive progressively, and respect user preferences. Start with a small primitive, compose meaningful placeholders for each section, and integrate them thoughtfully with your data-fetching strategy. The result is a smoother, more trustworthy React app that feels fast even when the network isn’t.
Related Posts
React Search Autocomplete: A Fast, Accessible Implementation with Hooks
Build a fast, accessible React search autocomplete with hooks, debouncing, ARIA, caching, and virtualization. Includes complete, production-ready code.
Mastering React Virtualized Lists: Performance Patterns and Tuning Guide
Build fast, memory‑efficient React virtualized lists with tuning tips for overscan, variable heights, a11y, and profiling—plus practical code examples.
React Dynamic Imports and Lazy Routes: A Practical Guide
A practical guide to React dynamic imports and lazy routes with React Router, preloading strategies, SSR notes, and performance pitfalls.