Skeleton screens are more effective than spinners for content loading because they show structure before data arrives. Implementing them in React requires a few specific patterns that keep the code clean and the behavior consistent across components.
This guide builds a reusable skeleton system from scratch, covering the CSS, the React component patterns, and the accessibility requirements that most tutorials skip.
The Core Pattern: Loading State Branching
In React, the standard pattern for a loading state is conditional rendering based on a boolean or the loading state of an async operation. The skeleton screen variant renders when isLoading is true; the real component renders when data is available.
function ArticleCard({ articleId }) {
const { data, isLoading } = useFetchArticle(articleId);
if (isLoading) {
return <ArticleCardSkeleton />;
}
return (
<div className="article-card">
<img src={data.image} alt={data.title} />
<h2>{data.title}</h2>
<p>{data.excerpt}</p>
</div>
);
}
The ArticleCardSkeleton component mirrors the structure of the real component with placeholder shapes. The key is that the skeleton and the real component should occupy the same space and follow the same layout so there is no shift when the transition happens.
The Skeleton CSS
The CSS for skeleton screens uses an animated gradient. Add this to your global stylesheet or CSS module:
@keyframes shimmer {
0% { background-position: -400px 0; }
100% { background-position: 400px 0; }
}
.skeleton {
background: linear-gradient(90deg, #e8e8e8 25%, #f4f4f4 50%, #e8e8e8 75%);
background-size: 800px 100%;
animation: shimmer 1.4s infinite linear;
border-radius: 4px;
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
background: #e8e8e8;
}
}
The prefers-reduced-motion media query disables the animation for users who have configured their OS to reduce motion. This is a requirement, not optional: continuous shimmer animations can cause problems for users with vestibular disorders. The MDN Web Docs cover prefers-reduced-motion and its browser support.
The Skeleton Component
Create a base Skeleton component that accepts width, height, and any additional class names:
function Skeleton({ width = '100%', height = '1em', className = '' }) {
return (
<span
className={`skeleton ${className}`}
style={{ width, height, display: 'block' }}
aria-hidden="true"
/>
);
}
Setting aria-hidden="true" prevents screen readers from announcing skeleton elements, which would be meaningless ("empty empty empty" for a list of text placeholders). The aria accessibility is handled separately at the container level.
The Skeleton Component Variant
For the ArticleCardSkeleton, match the structure of the real component as closely as possible:
function ArticleCardSkeleton() {
return (
<div className="article-card">
<Skeleton height="200px" className="skeleton-image" />
<Skeleton width="70%" height="1.5em" style={{ marginTop: '1rem' }} />
<Skeleton width="100%" height="1em" style={{ marginTop: '0.5rem' }} />
<Skeleton width="85%" height="1em" style={{ marginTop: '0.25rem' }} />
<Skeleton width="40%" height="1em" style={{ marginTop: '0.25rem' }} />
</div>
);
}
Each Skeleton element approximates the size and position of the real element it replaces. The image placeholder uses the same height as the actual image. The title placeholder is wider. The text lines decrease in width toward the end of the paragraph to mimic natural text wrapping.
Do not use identical-height bars for all placeholders, because that communicates nothing about the content structure and defeats the purpose of the skeleton screen.
Handling Lists
For lists of items, render the skeleton component multiple times. Use a fixed count rather than trying to derive the count from a skeleton query:
function ArticleList({ category }) {
const { data, isLoading } = useFetchArticles(category);
if (isLoading) {
return (
<ul>
{Array.from({ length: 6 }).map((_, i) => (
<li key={i}>
<ArticleCardSkeleton />
</li>
))}
</ul>
);
}
return (
<ul>
{data.map(article => (
<li key={article.id}>
<ArticleCard article={article} />
</li>
))}
</ul>
);
}
Six is a reasonable default for a content grid. It creates enough visual structure to communicate the layout without making the loading state feel excessive.
Accessibility at the Container Level
While individual skeleton elements should be aria-hidden, the container needs to communicate loading state to screen readers. Add aria-busy to the container:
function ArticleList({ category }) {
const { data, isLoading } = useFetchArticles(category);
return (
<section aria-busy={isLoading} aria-label="Article list">
{isLoading ? (
Array.from({ length: 6 }).map((_, i) => (
<ArticleCardSkeleton key={i} />
))
) : (
data.map(article => <ArticleCard key={article.id} article={article} />)
)}
</section>
);
}
Setting aria-busy="true" tells screen readers that the region's content is being updated. When loading completes and the real content renders, aria-busy becomes false and screen readers announce the content update. The W3C WAI-ARIA specification covers aria-busy semantics in detail.
For supplemental announcements, add a visually-hidden live region elsewhere in the component tree that announces when content has loaded:
<div
role="status"
aria-live="polite"
className="visually-hidden"
>
{!isLoading && data && `${data.length} articles loaded`}
</div>
Preventing Layout Shift
The largest cause of poor CLS scores with skeleton screens is a size mismatch between the skeleton and the real content. If the loaded content is 50px taller than the skeleton, the page jumps when data arrives.
The cleanest solution is to set min-height on the parent container to match the expected loaded height, and ensure skeleton elements are sized to fill that space. For dynamic content where height varies, wrapping each skeleton in a container with the correct dimensions prevents the worst layout shifts.
The web.dev documentation on Cumulative Layout Shift covers the measurement and improvement strategies in detail. Chrome DevTools also highlights layout shift in the Performance panel.
Handling Error States After Loading
A loading state that transitions to real content is the happy path. The error path is equally important and is missing from most skeleton screen implementations.
When a data fetch fails, the skeleton should be replaced by an error state, not simply removed or left visible. The error state should occupy approximately the same space as the skeleton so there is no significant layout shift when it appears. A good error state tells the user what happened, whether they can do anything about it, and what their options are.
function ArticleCard({ articleId }) {
const { data, isLoading, error } = useFetchArticle(articleId);
if (isLoading) {
return <ArticleCardSkeleton />;
}
if (error) {
return (
<div className="article-card article-card--error" role="alert">
<p>Could not load article.</p>
<button onClick={() => refetch()}>Try again</button>
</div>
);
}
return (
<div className="article-card">
<img src={data.image} alt={data.title} />
<h2>{data.title}</h2>
<p>{data.excerpt}</p>
</div>
);
}
The role="alert" on the error container ensures screen readers announce the error automatically, even if the user's focus has not moved to that element.
Empty States
A loading state that resolves with zero results also needs a specific design. An empty state is different from an error state: there was no failure, but there is also nothing to show. Common examples include an empty search results page, a dashboard with no data yet, and a filtered list with no matching items.
Empty states should explain why there are no results and, where appropriate, offer a path forward. "No articles found for this search. Try a different keyword." is more helpful than a blank container. The empty state should occupy the same space as the loaded content to prevent layout shift when switching between states with and without results.
Connecting to the Design System
The most maintainable skeleton implementation is one where every component has a corresponding skeleton variant, both are part of the same component module, and the base Skeleton class and keyframe animation come from the design system rather than being reimplemented per component.
For the design side of this system, including the decision framework for when to use skeleton screens versus progress bars versus spinners, the guide on loading states and skeleton screens covers the patterns that this implementation is built around.
The 137Foundry team builds skeleton component systems as part of the web development work on every project, treating loading states as a first-class design specification rather than an afterthought. The result is consistent, accessible loading behavior that holds up under real network conditions on real devices.

Photo by Ann H on Pexels
Top comments (0)