Build a Skeleton Loader
Learn how to build skeleton loading screens that give users instant visual feedback while content loads. Skeleton loaders reduce perceived wait time and prevent layout shift — a must-know pattern for any frontend role.
Table of Contents
What is a Skeleton Loader?
A skeleton loader (or skeleton screen) is a placeholder UI that mimics the shape of the real content while it loads. Instead of showing a blank page or a spinner, you show grey boxes and lines that match the layout of the incoming data — giving users an instant sense of structure.
Social Feeds
Facebook, LinkedIn, and Twitter all show skeleton cards while posts load. Users see the layout immediately.
E-Commerce
Product grids show skeleton cards with image placeholders, price lines, and rating bars while fetching catalog data.
Dashboards
Analytics dashboards show skeleton charts and stat cards so the layout doesn't jump when data arrives.
Why interviewers ask this
Skeleton loaders test your understanding of loading states, CSS animation, component composition, and UX thinking. It's a quick question that reveals whether you think about the user experience beyond just "make it work."
Why Not Spinners?
Spinners tell the user "something is happening" but give no hint about what's coming. Skeleton screens are better because they set expectations about the content layout.
| Spinner | Skeleton | |
|---|---|---|
| Perceived speed | Feels slower — user stares at a circle | Feels faster — content appears to load progressively |
| Layout shift | Content pops in and pushes things around | No shift — skeleton matches the final layout |
| Context | No hint about what's loading | Shape reveals the content type (card, list, profile) |
| Best for | Short waits (<1s), actions (submit, save) | Content loading (feeds, profiles, dashboards) |
Use both
Spinners and skeletons aren't mutually exclusive. Use spinners for user-initiated actions (button clicks, form submits) and skeletons for content that loads on page render.
Anatomy of a Skeleton
A skeleton is just a set of grey shapes that match the dimensions of the real content. The key principle: the skeleton layout should be identical to the loaded layout so there's zero shift when data arrives.
Match the real layout
Look at your loaded component. For every text line, image, or block, create a grey rectangle with the same width, height, and border-radius.
Use appropriate shapes
Circles for avatars (rounded-full), rounded rectangles for text lines (rounded-md), and squares for images. Vary widths to mimic natural text lengths.
Add animation
Apply a pulse or shimmer animation so the skeleton feels alive. A static grey box looks broken — animation signals 'loading in progress.'
Swap on load
When data arrives, replace the skeleton with the real component. The transition should be seamless — no layout jump.
Real content → Skeleton shape ───────────────────────────────────────── Avatar (64×64 circle) → div.w-16.h-16.rounded-full.bg-gray-200 Name ("Jane Cooper") → div.h-4.w-32.rounded-md.bg-gray-200 Bio (2 lines of text) → div.h-3.w-full + div.h-3.w-5/6 Stat number ("1,284") → div.h-8.w-16.rounded-md.bg-gray-200
Building the Skeleton Component
The pattern is simple: create a SkeletonCard component that mirrors the ProfileCard layout, but with grey animated divs instead of real content.
function SkeletonCard() { return ( <div className="w-full max-w-sm border border-gray-200 rounded-xl p-6"> {/* Avatar + name */} <div className="flex items-center gap-4 mb-4"> <div className="w-16 h-16 rounded-full bg-gray-200 animate-pulse" /> <div className="flex-1 space-y-2"> <div className="h-4 bg-gray-200 rounded-md w-32 animate-pulse" /> <div className="h-3 bg-gray-200 rounded-md w-24 animate-pulse" /> </div> </div> {/* Bio lines */} <div className="space-y-2 mb-5"> <div className="h-3 bg-gray-200 rounded-md w-full animate-pulse" /> <div className="h-3 bg-gray-200 rounded-md w-5/6 animate-pulse" /> <div className="h-3 bg-gray-200 rounded-md w-2/3 animate-pulse" /> </div> {/* Stats */} <div className="flex gap-6"> <div className="h-8 bg-gray-200 rounded-md w-16 animate-pulse" /> <div className="h-8 bg-gray-200 rounded-md w-16 animate-pulse" /> <div className="h-8 bg-gray-200 rounded-md w-16 animate-pulse" /> </div> </div> ); }
Wiring it up with loading state
export default function ProfilePage() { const [user, setUser] = useState<User | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { fetchUser().then((data) => { setUser(data); setLoading(false); }); }, []); return loading ? <SkeletonCard /> : <ProfileCard user={user!} />; }
Keep it simple
Don't overthink the skeleton. It doesn't need to be pixel-perfect — just close enough that the user recognizes the shape. Varying the widths of text lines (w-full, w-5/6, w-2/3) makes it look natural.
Animation Techniques
There are two common animation styles for skeletons: pulse (opacity fade) and shimmer (gradient sweep). Both signal "loading" — pick whichever fits your design system.
Pulse (Tailwind built-in)
The simplest approach. Tailwind's animate-pulse fades opacity between 1 and 0.5 on a 2s loop.
// Just add the class — that's it <div className="h-4 bg-gray-200 rounded-md animate-pulse" /> // Under the hood, Tailwind generates: // @keyframes pulse { // 0%, 100% { opacity: 1 } // 50% { opacity: 0.5 } // } // .animate-pulse { animation: pulse 2s ease-in-out infinite }
Shimmer (custom CSS)
A gradient that sweeps left-to-right, creating a "shine" effect. More polished but requires custom CSS.
@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } .skeleton-shimmer { background: linear-gradient( 90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75% ); background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; }
// Replace animate-pulse with your custom class: <div className="h-4 rounded-md skeleton-shimmer" /> // Or inline with Tailwind arbitrary values: <div className="h-4 rounded-md" style={{ background: "linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%)", backgroundSize: "200% 100%", animation: "shimmer 1.5s ease-in-out infinite", }} />
Which to use?
Pulse is simpler and works great for most cases. Shimmer looks more polished and is what Facebook/LinkedIn use. In an interview, start with pulse (one class) and mention shimmer as an enhancement.
Edge Cases & Enhancements
The basic skeleton covers the happy path. Here are things interviewers might ask about:
| Topic | Approach |
|---|---|
| Reusable skeleton primitives | Create generic components like <SkeletonLine width="w-32" /> and <SkeletonCircle size={64} /> that can be composed into any layout |
| Accessibility | Add aria-busy="true" to the container and role="status" with a screen-reader-only "Loading" label |
| Reduced motion | Respect prefers-reduced-motion — disable animation and show static grey boxes instead |
| Error state | If the fetch fails, don't leave the skeleton forever. Show an error message with a retry button |
| Fast loads | If data loads in <200ms, the skeleton flashes awkwardly. Add a minimum display time or delay showing the skeleton until 200ms have passed |
| Staggered animation | Add increasing animation-delay to each skeleton row so they pulse in sequence rather than all at once |
function SkeletonLine({ width = "w-full" }: { width?: string }) { return ( <div className={`h-3 bg-gray-200 rounded-md animate-pulse ${width}`} /> ); } function SkeletonCircle({ size = 48 }: { size?: number }) { return ( <div className="rounded-full bg-gray-200 animate-pulse" style={{ width: size, height: size }} /> ); } // Usage — compose any skeleton layout: <div className="flex items-center gap-4"> <SkeletonCircle size={64} /> <div className="space-y-2"> <SkeletonLine width="w-32" /> <SkeletonLine width="w-24" /> </div> </div>
Reduced motion
Always respect the user's motion preferences. In Tailwind, use motion-safe:animate-pulse instead of animate-pulse to automatically disable animation when the user has prefers-reduced-motion enabled.
Common Interview Follow-up Questions
After building the skeleton loader, expect these follow-ups:
Q:When should you use a skeleton vs a spinner?
A: Skeletons are best for content loading (pages, feeds, cards) where you know the layout ahead of time. Spinners are better for user-initiated actions (form submit, file upload) where the result layout is unpredictable. Use skeletons for reads, spinners for writes.
Q:How do you prevent the skeleton from flashing on fast loads?
A: Two approaches: (1) Don't show the skeleton until 200ms have passed — if data arrives before that, skip the skeleton entirely. (2) Show the skeleton for a minimum of 300ms so it doesn't flash. Use a combination of setTimeout and state flags.
Q:How do you make skeletons accessible?
A: Add aria-busy='true' to the loading container and role='status' with an aria-label='Loading content' for screen readers. When loading completes, set aria-busy='false'. Also respect prefers-reduced-motion by disabling animation.
Q:How would you build a generic Skeleton component?
A: Create primitives: SkeletonLine (configurable width/height), SkeletonCircle (configurable size), SkeletonRect (configurable dimensions). Compose them to match any layout. Accept a 'variant' prop for common patterns like 'card', 'list-item', 'avatar-with-text'.
Q:What's the difference between pulse and shimmer animations?
A: Pulse fades opacity in and out (simple, built into Tailwind). Shimmer sweeps a gradient highlight across the element (more polished, used by Facebook/LinkedIn). Pulse is easier to implement; shimmer looks more professional. Both signal 'loading in progress.'
Q:How does a skeleton loader help with Cumulative Layout Shift (CLS)?
A: The skeleton reserves the exact space the real content will occupy. When data arrives, the skeleton is replaced with content of the same dimensions — no elements shift. Without a skeleton, content pops in and pushes everything below it down, causing CLS.
Q:How would you handle a skeleton for dynamic-height content?
A: Estimate the average height based on typical content. For lists, show a fixed number of skeleton rows (e.g., 5). For text blocks, show 2-3 lines of varying width. It doesn't need to be exact — close enough prevents the worst layout shifts.
Ready to build it yourself?
We've set up a profile card with a mock API. Build the skeleton loader with animated placeholders and swap it when data arrives.
Built for developers, by developers. Happy coding! 🚀