ReactCSS AnimationLoading StatesUX PatternsAccessibility

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.

15 min read7 sections
01

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."

02

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.

SpinnerSkeleton
Perceived speedFeels slower — user stares at a circleFeels faster — content appears to load progressively
Layout shiftContent pops in and pushes things aroundNo shift — skeleton matches the final layout
ContextNo hint about what's loadingShape reveals the content type (card, list, profile)
Best forShort 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.

03

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.

1

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.

2

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.

3

Add animation

Apply a pulse or shimmer animation so the skeleton feels alive. A static grey box looks broken — animation signals 'loading in progress.'

4

Swap on load

When data arrives, replace the skeleton with the real component. The transition should be seamless — no layout jump.

Skeleton shape examplestext
Real contentSkeleton 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
04

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.

SkeletonCard.tsxtypescript
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

ProfilePage.tsxtypescript
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.

05

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.

Pulse animationtypescript
// 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.

Shimmer animationcss
@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;
}
Using shimmer in JSXtypescript
// 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.

06

Edge Cases & Enhancements

The basic skeleton covers the happy path. Here are things interviewers might ask about:

TopicApproach
Reusable skeleton primitivesCreate generic components like <SkeletonLine width="w-32" /> and <SkeletonCircle size={64} /> that can be composed into any layout
AccessibilityAdd aria-busy="true" to the container and role="status" with a screen-reader-only "Loading" label
Reduced motionRespect prefers-reduced-motion — disable animation and show static grey boxes instead
Error stateIf the fetch fails, don't leave the skeleton forever. Show an error message with a retry button
Fast loadsIf data loads in <200ms, the skeleton flashes awkwardly. Add a minimum display time or delay showing the skeleton until 200ms have passed
Staggered animationAdd increasing animation-delay to each skeleton row so they pulse in sequence rather than all at once
Reusable skeleton primitivestypescript
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.

07

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! 🚀