By following this guide, you will build a fully reusable animated skeleton loading system in React that works across card grids, dashboards, and detail pages. Expect to spend around 60–90 minutes from setup to a production-ready implementation you can drop into any project.

What You'll Build

  • A composable Skeleton base component with CSS shimmer animation using no external dependencies
  • Preset variants — SkeletonCard, SkeletonText, and SkeletonAvatar — that mirror your real UI shapes
  • A useSkeleton hook that handles loading state transitions and prevents layout shift
  • An accessible implementation that respects prefers-reduced-motion per WCAG 2.2
  • A working demo page showing skeleton → real content swap for a product card grid, tested on React 18+ and Node 20+

Prerequisites

  • Node.js 20+ and pnpm 9 (or npm 10) installed
  • A React 18+ project — Vite or Next.js 15 both work fine
  • Basic familiarity with React hooks and CSS modules or Tailwind CSS
  • TypeScript is recommended but all examples include plain JS equivalents

Why Skeleton Loaders Matter More Than Spinners

Skeleton screens have been the dominant loading pattern since LinkedIn popularised them in 2013. As of 2026, research consistently shows they reduce perceived wait time by roughly 20–30% compared to spinner-based loaders. The reason is simple: skeletons show the user where content will appear, reducing uncertainty. For SMBs in Australia, Singapore, Canada, and the US competing on mobile-first experiences, that perception gap translates directly into lower bounce rates.

Spinners communicate "something is happening." Skeletons communicate "your content is almost here." That difference matters more than ever on mobile networks where real latency varies widely.

Step 1 — Create the Base Skeleton Component

Why does a single base component matter?

Building one flexible base component lets you compose every variant from the same animation definition. It avoids duplicated keyframe declarations and keeps your bundle lean.

Create src/components/Skeleton/Skeleton.tsx:

import React from 'react';
import styles from './Skeleton.module.css';

interface SkeletonProps {
  width?: string | number;
  height?: string | number;
  borderRadius?: string | number;
  className?: string;
  'aria-label'?: string;
}

export function Skeleton({
  width = '100%',
  height = '1rem',
  borderRadius = '0.375rem',
  className = '',
  'aria-label': ariaLabel = 'Loading…',
}: SkeletonProps) {
  return (
    
); }

Now create src/components/Skeleton/Skeleton.module.css:

.skeleton {
  background: #e2e8f0;
  background-image: linear-gradient(
    90deg,
    #e2e8f0 0px,
    #f1f5f9 40%,
    #e2e8f0 80%
  );
  background-size: 200% 100%;
  animation: shimmer 1.4s linear infinite;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

@media (prefers-reduced-motion: reduce) {
  .skeleton {
    animation: none;
    opacity: 0.7;
  }
}

Expected result: A grey rectangle with a left-to-right shimmer that automatically stops animating when the user has reduced motion enabled in their OS settings.

Common pitfall: Setting background-size to 100% produces a hard-edge flash rather than a smooth sweep. The 200% value creates the travelling highlight effect.

Step 2 — Build Preset Variants

When should you create a new variant vs reuse the base?

Create a named variant whenever the same shape repeats across three or more screens. One-off shapes can just use <Skeleton> directly with inline props.

Create src/components/Skeleton/index.ts and the three most common variants:

// SkeletonText.tsx
import { Skeleton } from './Skeleton';

interface SkeletonTextProps {
  lines?: number;
  lastLineWidth?: string;
}

export function SkeletonText({ lines = 3, lastLineWidth = '60%' }: SkeletonTextProps) {
  return (
    
{Array.from({ length: lines }).map((_, i) => ( ))}
); } // SkeletonAvatar.tsx import { Skeleton } from './Skeleton'; interface SkeletonAvatarProps { size?: number; } export function SkeletonAvatar({ size = 48 }: SkeletonAvatarProps) { return ; } // SkeletonCard.tsx import { Skeleton } from './Skeleton'; import { SkeletonText } from './SkeletonText'; import { SkeletonAvatar } from './SkeletonAvatar'; export function SkeletonCard() { return (
); }

Expected result: Three composable components you can drop into any layout without repeating animation code.

Step 3 — Build the useSkeleton Hook

What does the hook add that local useState doesn't?

The hook standardises a minimum display time. Without it, skeletons flash for just 50–100ms on fast connections — which is visually jarring and defeats the purpose entirely.

// src/hooks/useSkeleton.ts
import { useState, useEffect, useRef } from 'react';

interface UseSkeletonOptions {
  minDisplayMs?: number;
}

export function useSkeleton(
  isLoading: boolean,
  { minDisplayMs = 400 }: UseSkeletonOptions = {}
) {
  const [showSkeleton, setShowSkeleton] = useState(isLoading);
  const startTime = useRef(null);

  useEffect(() => {
    if (isLoading) {
      startTime.current = Date.now();
      setShowSkeleton(true);
    } else if (startTime.current !== null) {
      const elapsed = Date.now() - startTime.current;
      const remaining = minDisplayMs - elapsed;

      if (remaining > 0) {
        const timer = setTimeout(() => setShowSkeleton(false), remaining);
        return () => clearTimeout(timer);
      } else {
        setShowSkeleton(false);
      }
    }
  }, [isLoading, minDisplayMs]);

  return showSkeleton;
}

Expected result: The skeleton always shows for at least 400ms, preventing the jarring flash on fast responses.

Pro tip: Set minDisplayMs to 0 during automated testing so your test suite does not need artificial delays.

Step 4 — Wire It Into a Product Card Grid

This is where everything comes together. Here is a realistic usage example using a simulated data fetch:

// src/pages/ProductGrid.tsx
import { useState, useEffect } from 'react';
import { useSkeleton } from '../hooks/useSkeleton';
import { SkeletonCard } from '../components/Skeleton/SkeletonCard';
import { ProductCard } from '../components/ProductCard';

const SKELETON_COUNT = 6;

export function ProductGrid() {
  const [products, setProducts] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const showSkeleton = useSkeleton(isLoading, { minDisplayMs: 500 });

  useEffect(() => {
    fetch('/api/products')
      .then((res) => res.json())
      .then((data) => {
        setProducts(data);
        setIsLoading(false);
      });
  }, []);

  return (
    
{showSkeleton ? Array.from({ length: SKELETON_COUNT }).map((_, i) => ( )) : products.map((product) => ( ))}
); }

Expected result: Six skeleton cards render instantly on page load and transition cleanly to real product cards once data arrives — no layout shift because the grid dimensions are identical between states.

Common pitfall: Not adding aria-live="polite" to the container means screen reader users get no feedback when the real content appears. Always include it.

Step 5 — Test and Validate

How do you test a skeleton system without mocking timers everywhere?

Use Vitest with a simple async approach. Pass minDisplayMs: 0 in test context so you do not need vi.useFakeTimers().

// Skeleton.test.tsx — Vitest + React Testing Library
import { render, screen } from '@testing-library/react';
import { Skeleton } from './Skeleton';

test('renders with correct role and aria attributes', () => {
  render();
  const el = screen.getByRole('status');
  expect(el).toHaveAttribute('aria-busy', 'true');
  expect(el).toHaveAttribute('aria-label', 'Loading card');
});

Run your suite with pnpm test. All skeleton components should pass with zero timers needed.

Pro tip: Use Chrome DevTools Performance panel with CPU throttling set to 4× slowdown to simulate real mobile conditions and verify your shimmer still reads clearly at lower frame rates.

Step 6 — Add Dark Mode Support

Update Skeleton.module.css to respond to the user's colour scheme preference: