Most React codebases end up with three or four one-off skeleton implementations scattered across different features, each one slightly different, none of them reused. Here's a straightforward way to build one reusable primitive that covers most cases, plus the composition pattern to build feature-specific skeletons on top of it.
Photo by Arun Antony on Unsplash
Step 1: Build the Base Skeleton Primitive
Start with a single low-level component that renders a placeholder block of a given size and shape. Everything else is composed from this.
function Skeleton({ width = '100%', height = '1rem', radius = '4px', className = '' }) {
return (
<div
className={`skeleton-pulse ${className}`}
style={{ width, height, borderRadius: radius }}
aria-hidden="true"
/>
);
}
Note the aria-hidden="true". The skeleton itself is a purely visual placeholder; screen readers shouldn't announce it as content. The loading announcement happens separately, covered in Step 4.
Step 2: Define the Pulse Animation
Keep the animation in CSS rather than JavaScript, so it runs on the compositor thread and doesn't compete with whatever else is happening during the load.
.skeleton-pulse {
background: linear-gradient(90deg, #e2e2e2 25%, #f0f0f0 50%, #e2e2e2 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
If your project already uses Tailwind CSS, the built-in animate-pulse utility does something visually similar with less setup, worth considering before building a custom keyframe animation.
Step 3: Compose Feature-Specific Skeletons
Build the shapes you actually need out of the base primitive, matching the real layout as closely as possible. For a card in a list:
function CardSkeleton() {
return (
<div className="card">
<Skeleton width="48px" height="48px" radius="50%" />
<div style={{ flex: 1, marginLeft: '12px' }}>
<Skeleton width="60%" height="0.9rem" />
<Skeleton width="40%" height="0.75rem" className="mt-2" />
</div>
</div>
);
}
function CardListSkeleton({ count = 5 }) {
return (
<>
{Array.from({ length: count }).map((_, i) => (
<CardSkeleton key={i} />
))}
</>
);
}
Matching the count to a realistic number of items matters more than it seems. A skeleton showing three cards when the real list usually renders eight causes a visible layout jump the moment real data arrives.
Step 4: Wire It Into Data Fetching With an Accessible Announcement
The skeleton itself should not be announced to screen readers, but the transition from loading to loaded should be. Pair the skeleton with an aria-live region that announces completion:
function CardList({ items, isLoading }) {
return (
<div>
<div aria-live="polite" className="sr-only">
{!isLoading && `${items.length} items loaded`}
</div>
{isLoading ? <CardListSkeleton /> : items.map(renderCard)}
</div>
);
}
MDN's documentation on ARIA live regions covers how aria-live="polite" queues the announcement without interrupting whatever the screen reader user is currently doing, which matters more for perceived quality than the visual polish of the shimmer animation itself.
Step 5: Add a Timeout Fallback
A skeleton with no upper bound will render forever if the underlying request stalls. Wrap the loading state with a timeout that swaps to an error state after a reasonable window:
function useTimeoutFallback(isLoading, timeoutMs = 10000) {
const [timedOut, setTimedOut] = useState(false);
useEffect(() => {
if (!isLoading) {
setTimedOut(false);
return;
}
const id = setTimeout(() => setTimedOut(true), timeoutMs);
return () => clearTimeout(id);
}, [isLoading, timeoutMs]);
return timedOut;
}
Render a distinct "this is taking longer than expected, retry?" state when timedOut is true, rather than letting the skeleton spin indefinitely. This is the detail most implementations skip entirely, and it's the one that turns a stalled request into a genuinely confusing dead-end for the user.
"A skeleton component that isn't reusable across at least three or four features usually means the team built it once, under deadline, and never went back to generalize it. It's worth the extra hour up front." - Dennis Traina, founder of 137Foundry
Step 6: Handle the Error Case Distinctly From the Loading Case
A timeout fallback answers "this is taking too long." It doesn't answer "this failed outright." Those need separate UI states, because a user who sees the same generic message for both a slow request and a broken one has no useful information about whether retrying is likely to help.
function CardList({ items, isLoading, error }) {
const timedOut = useTimeoutFallback(isLoading);
if (error) {
return <ErrorState message="Couldn't load items." onRetry={refetch} />;
}
if (timedOut) {
return <ErrorState message="This is taking longer than expected." onRetry={refetch} />;
}
return (
<div>
<div aria-live="polite" className="sr-only">
{!isLoading && `${items.length} items loaded`}
</div>
{isLoading ? <CardListSkeleton /> : items.map(renderCard)}
</div>
);
}
Keeping these as distinct branches, rather than collapsing them into a single "something went wrong" catch-all, makes the component's behavior easier to reason about and easier to test. Each branch has one job: show the skeleton, show the timeout message, show the error message, or show the real content.
Step 7: Extract a Hook for Reuse Across Features
Once this pattern exists in one place, the fastest way to keep it consistent across a codebase is extracting the loading/timeout/error logic into a shared hook rather than reimplementing it per feature:
function useLoadingState(fetchFn, { timeoutMs = 10000 } = {}) {
const [state, setState] = useState({ isLoading: true, error: null, data: null });
const timedOut = useTimeoutFallback(state.isLoading, timeoutMs);
useEffect(() => {
let cancelled = false;
fetchFn()
.then((data) => !cancelled && setState({ isLoading: false, error: null, data }))
.catch((error) => !cancelled && setState({ isLoading: false, error, data: null }));
return () => { cancelled = true; };
}, [fetchFn]);
return { ...state, timedOut };
}
Every feature that needs a loading state, skeleton, timeout, and error handling calls this one hook instead of rebuilding the same useState/useEffect combination with slightly different edge case handling each time. This is usually the point where a codebase's five or six inconsistent loading implementations converge into one.
A Note on Testing This Component
Skeleton components are easy to skip in test coverage because they feel purely presentational. Two tests are worth writing anyway: one confirming the skeleton renders when isLoading is true, and one confirming the aria-live region announces the correct item count once loading resolves. The second one is the test most codebases are missing, and it's the one that actually catches regressions in the accessibility behavior rather than just the visual behavior, which is easy to eyeball correct in a quick manual check but easy to silently break in a later refactor.
test('announces item count when loading completes', () => {
const { rerender, getByText } = render(<CardList items={[]} isLoading={true} />);
rerender(<CardList items={[item1, item2]} isLoading={false} />);
expect(getByText('2 items loaded')).toBeInTheDocument();
});
Putting It Together
The full pattern, base primitive, composed shapes, accessible announcement, timeout fallback, and shared hook, is roughly 120 lines of code total, and it covers the majority of loading scenarios in a typical React application. The React documentation on Suspense is worth reading alongside this pattern if your data fetching layer supports it, since Suspense can replace some of the manual isLoading boolean plumbing shown here with a more declarative API, particularly for newer codebases built around React's concurrent rendering features.
For the broader design decisions behind when to use a skeleton versus a spinner versus a progress bar, 137Foundry's web development team covers the full framework in a companion guide on designing loading states that don't feel like lying to users.
Top comments (0)