Skeleton Loader in React: Pulse Animation and Smart Loading States
Build skeleton loaders in React with CSS pulse animations and smart loading states. Covers Tailwind, custom hooks, and when to skip spinners entirely.
Why Skeleton Loaders Beat Spinners
Spinners lie to users. They say 'something is happening' but give zero information about what or how long. A skeleton loader, on the other hand, shows the approximate shape of the content that's coming — so users don't feel like they're staring into a void.
The research backs this up. Facebook introduced skeleton screens around 2013, and teams have been measuring perceived performance gains ever since. The key insight: users tolerate waits better when they can see a rough layout forming. It's the same reason a progress bar feels faster than a spinning indicator, even at the same actual duration.
Honestly, the spinner has one good use case — indeterminate progress on a small icon button. Everything else? Build the skeleton. Your users will thank you, your Lighthouse scores won't change, but your bounce rate might.
Worth noting: skeleton loaders also double as a layout reservation. They prevent the jarring content shift that happens when images and text pop into a blank page. If you've ever fought cumulative layout shift (CLS) for your Core Web Vitals score, a well-built skeleton gets you most of the way there for free.
The Core Pulse Animation in CSS and Tailwind
The pulse animation is just a opacity or background-position keyframe that cycles. In raw CSS, you're looking at roughly 3 lines of animation definition and 1 utility class. Tailwind ships animate-pulse since v2.1, which handles the opacity fade for you.
Here's the simplest possible skeleton block in Tailwind:
function SkeletonBlock({ className = '' }) {
return (
<div
className={`animate-pulse bg-gray-200 dark:bg-gray-700 rounded ${className}`}
/>
);
}
// Usage
<SkeletonBlock className="h-4 w-3/4 mb-2" />
<SkeletonBlock className="h-4 w-1/2 mb-2" />
<SkeletonBlock className="h-32 w-full" />That animate-pulse class runs a keyframe from opacity 1 down to 0.5 and back, on a 2-second loop. You can override the duration in your tailwind.config.js if 2s feels too slow for your brand. A snappier 1.5s often reads as more 'alive'.
If you're not on Tailwind, the equivalent vanilla CSS is:
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.skeleton {
background-color: #e5e7eb;
border-radius: 4px;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}Building a Reusable Skeleton Component in React
The goal is a component flexible enough to cover cards, list rows, profile blocks, and data tables — without you writing a separate skeleton for each. One approach: pass width, height, and an optional shape prop.
function Skeleton({ width = '100%', height = 16, shape = 'rect', className = '' }) {
const baseStyle = {
width,
height: typeof height === 'number' ? `${height}px` : height,
borderRadius: shape === 'circle' ? '50%' : '6px',
};
return (
<div
className={`animate-pulse bg-gray-200 dark:bg-gray-700 ${className}`}
style={baseStyle}
aria-hidden="true"
/>
);
}
// Card skeleton example
function CardSkeleton() {
return (
<div className="p-4 space-y-3 border border-gray-100 rounded-xl">
<div className="flex items-center gap-3">
<Skeleton shape="circle" width={40} height={40} />
<div className="flex-1 space-y-2">
<Skeleton height={12} width="60%" />
<Skeleton height={10} width="40%" />
</div>
</div>
<Skeleton height={120} />
<Skeleton height={12} width="80%" />
<Skeleton height={12} width="50%" />
</div>
);
}Notice the aria-hidden="true" on each element. Screen readers don't need to announce 'loading placeholder rectangle' — that's noise. The loading state itself should be communicated via an aria-live region elsewhere, not through the visual skeleton elements.
That said, keep the skeleton's DOM structure as close to the real content as possible. If your card has a 48px avatar circle and two lines of text, mirror that in the skeleton. It's the shape-matching that creates the perceived continuity when content loads in.
One more thing — don't animate every single pixel. If you have a complex skeleton with 20 elements, they'll all pulse in sync by default. That's fine. But if you want a shimmer effect instead of a flat pulse, you need a slightly different approach (covered below).
Shimmer vs Pulse: Picking the Right Effect
Pulse fades the whole element in and out. Shimmer moves a highlight across the surface, like light reflecting off metal. LinkedIn uses shimmer. Most card-heavy interfaces do. Pulse is simpler to implement; shimmer looks more polished at the cost of a gradient animation.
Here's a shimmer implementation that doesn't require JavaScript — pure CSS gradient animation:
.shimmer {
background: linear-gradient(
90deg,
#e5e7eb 25%,
#f3f4f6 50%,
#e5e7eb 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}In practice, shimmer works best on light backgrounds. On dark UIs — say, a dark mode dashboard — pulse reads better because the gradient highlight doesn't have enough contrast to pop. If you're building dark-first, stick with pulse and adjust the gray tones (try #374151 to #4b5563 for a good dark range).
The glassmorphism components on Empire UI use a custom variant of shimmer that accounts for the blurred backdrop — something worth stealing if you're building translucent card skeletons.
A Custom useLoading Hook for Smart State Management
You don't want skeleton logic scattered across every component. A custom hook cleans this up fast. The idea: track isLoading, data, and error in one place, and let components just consume the result.
import { useState, useEffect } from 'react';
function useLoading(fetchFn, deps = []) {
const [state, setState] = useState({
data: null,
isLoading: true,
error: null,
});
useEffect(() => {
let cancelled = false;
setState(prev => ({ ...prev, isLoading: true, error: null }));
fetchFn()
.then(data => {
if (!cancelled) setState({ data, isLoading: false, error: null });
})
.catch(error => {
if (!cancelled) setState({ data: null, isLoading: false, error });
});
return () => { cancelled = true; };
}, deps);
return state;
}
// In your component:
function UserCard({ userId }) {
const { data: user, isLoading, error } = useLoading(
() => fetchUser(userId),
[userId]
);
if (isLoading) return <CardSkeleton />;
if (error) return <ErrorState message={error.message} />;
return <Card user={user} />;
}The cancelled flag prevents state updates on unmounted components — a bug that's bitten every React developer at least once. Don't skip it.
Quick aside: if you're already on React Query or SWR, you get isLoading for free and you'd wire the skeleton directly to that flag. The hook above is for teams that don't want a data-fetching library dependency just for loading states.
Also worth adding a minimum display duration. If your API responds in 80ms, flashing the skeleton for 80ms is actually *worse* than no skeleton — it's a visual flicker. A 300ms minimum render time for the skeleton makes the transition feel intentional rather than glitchy.
Skeleton Loaders with Tailwind CSS: Patterns for Real Layouts
Tailwind's utility-first approach means you can sketch a skeleton directly in JSX without touching a CSS file. The width utilities — w-1/4, w-3/4, w-full — are perfect for approximating text line lengths at different nesting levels.
For a typical blog post list, you'd structure skeletons at three levels: the hero image placeholder (full width, fixed height around 200px), the title line (70% width, 20px height), and the excerpt lines (two lines at 90% and 60%). That three-tier structure matches almost every card layout you'll encounter.
Look, the temptation is to perfectly match every pixel of the real layout. Don't. The skeleton needs to convey *shape and structure*, not exact dimensions. Users are pattern-matching, not measuring. Within 20px of the real layout is more than good enough.
If you're building a dashboard or tool UI, check out how browse the components on Empire UI handles loading states — there are patterns in the data table and chart components that translate directly to skeleton implementations.
Accessibility and Performance Considerations
Two things get missed constantly with skeleton loaders: announcing the loading state to screen readers, and not running animations for users who prefer reduced motion. Both are 10-minute fixes.
For accessibility, add a visually-hidden live region that announces when loading starts and ends:
// In your root layout or a loading context provider
<div aria-live="polite" className="sr-only">
{isLoading ? 'Loading content…' : 'Content loaded.'}
</div>For reduced motion, wrap your animation in a media query. Tailwind's motion-safe:animate-pulse utility does this in one class — users with prefers-reduced-motion: reduce set in their OS will see a static placeholder instead of a pulsing one. That's the correct behavior since 2021 WCAG 2.2 guidance on animation.
On performance: CSS animations on opacity and transform are GPU-accelerated and won't cause layout recalculations. The shimmer gradient animation using background-position is slightly heavier but still safe. What you want to avoid is animating width, height, or anything that triggers layout — that's how you accidentally tank your FPS on mid-range Android devices. Stick to opacity and transform, and you're fine. If you want more animation ideas that stay performant, the tailwind-css-animations article goes deep on what's safe to animate.
FAQ
A spinner communicates 'waiting' with no layout information. A skeleton shows the approximate shape of incoming content, which reduces perceived wait time and prevents layout shift when content arrives.
Use animate-pulse for dark UIs and quick implementations — it's one Tailwind class. Build shimmer for light-mode card-heavy interfaces where the gradient highlight has enough contrast to read well.
Set aria-hidden="true" on all skeleton elements, add a visually-hidden aria-live region to announce loading state, and use motion-safe:animate-pulse so users with reduced motion preferences get a static placeholder.
Yes — just wire the skeleton to their built-in isLoading flag. Both libraries expose it out of the box, so you skip the custom hook and render your skeleton component when isLoading is true.