Skip to main content

Skeleton Loading Component Design and Implementation

Comprehensive low-level design for a reusable skeleton loading UI component with performance optimizations and accessibility considerations.

This document presents a production-ready skeleton loading component architecture that enhances perceived performance while maintaining accessibility standards across various frontend frameworks and use cases.

1. Problem Statement & Requirements

Clarifying questions

  1. What is the target browser support matrix (ES6+ browsers, IE11, mobile Safari)?
  2. Should the skeleton loader support dark mode and theme switching?
  3. Will this be used for static content or primarily for API-driven data loading?
  4. Do we need to support offline mode with fallback skeletons?
  5. What level of accessibility is required (ARIA roles, keyboard navigation)?
  6. Will this component be used in i18n contexts with varying text lengths?
  7. Are there existing design system components or styling libraries we must integrate with?

Functional requirements

  • Display animated skeleton placeholders that mimic the shape and size of actual content
  • Support multiple skeleton variants (text, image, card, list item)
  • Allow customization of animation speed and color
  • Provide a way to show/hide skeletons based on loading state
  • Support responsive behavior that adapts to different screen sizes
  • Enable composition of multiple skeleton elements in a layout

Non-functional requirements

  • Performance: Must not block main thread or cause layout shifts
  • Accessibility: Should be keyboard navigable and screen reader friendly
  • Scalability: Should work efficiently with large lists of skeletons
  • Maintainability: Should be easy to extend and customize with themes

Constraints and assumptions

  • Assumption: The component will be used in React applications with TypeScript
  • Constraint: Must work without external dependencies beyond core React and styling libraries
  • Assumption: The component will be used in conjunction with data fetching libraries like React Query or SWR
  • Constraint: Animation should work across all modern browsers (Chrome, Firefox, Safari, Edge)
  • Assumption: Component will be used in a design system that supports theming

Out of scope

  • Server-side rendering support (SSR)
  • Complex animation libraries beyond CSS animations
  • Internationalization string handling (this is a UI component, not translation layer)
  • Integration with specific backend systems or data models
  • Custom animation timing functions beyond standard CSS easing
  • Touch gesture support (mobile-specific interactions)

2. Component Architecture

graph TD
    A[SkeletonLoading] --> B[SkeletonLine]
    A --> C[SkeletonImage]
    A --> D[SkeletonButton]
    A --> E[SkeletonCard]
    B --> F[LineContent]
    C --> G[ImageContent]
    D --> H[ButtonContent]
    E --> I[CardContent]

Component Tree and Structure

The SkeletonLoading system consists of a root component that composes various skeleton variants. The architecture follows a composition pattern where:

  • Smart Components: SkeletonLoading (container that orchestrates the skeleton state)
  • Dumb Components: SkeletonLine, SkeletonImage, SkeletonButton, SkeletonCard (presentational components that render the visual skeleton)

TypeScript Props Contracts

// SkeletonLoading.tsx
interface SkeletonLoadingProps {
  /** Number of skeleton lines to render */
  count?: number;
  /** Whether to show image skeleton */
  showImage?: boolean;
  /** Whether to show button skeleton */
  showButton?: boolean;
  /** Whether to show card skeleton */
  showCard?: boolean;
  /** Custom width for skeletons */
  width?: string | number;
  /** Custom height for skeletons */
  height?: string | number;
  /** Animation type - 'pulse' or 'wave' */
  animation?: 'pulse' | 'wave';
  /** Custom class name for styling */
  className?: string;
}

// SkeletonLine.tsx
interface SkeletonLineProps {
  width?: string | number;
  height?: string | number;
  animation?: 'pulse' | 'wave';
  className?: string;
}

// SkeletonImage.tsx
interface SkeletonImageProps {
  width?: string | number;
  height?: string | number;
  animation?: 'pulse' | 'wave';
  className?: string;
}

// SkeletonButton.tsx
interface SkeletonButtonProps {
  width?: string | number;
  height?: string | number;
  animation?: 'pulse' | 'wave';
  className?: string;
}

// SkeletonCard.tsx
interface SkeletonCardProps {
  width?: string | number;
  height?: string | number;
  animation?: 'pulse' | 'wave';
  className?: string;
}

UI State Machine

stateDiagram-v2
    [*] --> Idle
    Idle --> Loading : startLoading()
    Loading --> Loaded : dataLoaded()
    Loaded --> Idle : reset()
    Loading --> Error : fetchError()
    Error --> Idle : retry()

Component Responsibilities

  • SkeletonLoading: Main orchestrator that manages the overall loading state and composes child skeletons
  • SkeletonLine: Renders animated line-based skeleton with configurable dimensions
  • SkeletonImage: Renders image-shaped skeleton with appropriate animation
  • SkeletonButton: Renders button-shaped skeleton with proper sizing and animation
  • SkeletonCard: Renders card-based skeleton with consistent padding and layout

Reusability Decisions

All skeleton components are designed to be:

  • Highly reusable across different feature areas
  • Stylable through className and custom props
  • Animation agnostic to support different visual treatments
  • Accessible with proper ARIA attributes and semantic structure

Component Lifecycle Considerations

// Example lifecycle in SkeletonLoading component
const SkeletonLoading = ({ 
  count = 1, 
  showImage = false,
  showButton = false,
  animation = 'pulse'
}: SkeletonLoadingProps) => {
  const [isLoading, setIsLoading] = useState(true);
  
  useEffect(() => {
    // Component mounts with loading state
    return () => {
      // Cleanup if needed (e.g., cancel animations)
    };
  }, []);

  const handleDataLoaded = () => {
    setIsLoading(false);
  };

  return (
    <div className="skeleton-container">
      {isLoading ? (
        <>
          {Array.from({ length: count }).map((_, i) => (
            <SkeletonLine 
              key={i} 
              animation={animation}
            />
          ))}
          {showImage && <SkeletonImage animation={animation} />}
          {showButton && <SkeletonButton animation={animation} />}
        </>
      ) : (
        <div className="content">Actual content here</div>
      )}
    </div>
  );
};

The component lifecycle handles mounting with initial loading state, proper cleanup on unmount, and state transitions between idle/loading/loaded/error states. The animation is managed through CSS classes that are applied conditionally based on the loading state and animation type.

3. State Management Design

Local state vs. global state decisions

For a Skeleton Loading component, the core state is primarily local to each component instance. The skeleton should manage its own visibility and lifecycle without requiring global state management. However, we may need to coordinate with a parent component or global loading context for complex scenarios like page-level loading states.

State management approach

We'll use React's built-in state management (useState) for local component state and Context API for global loading coordination. The Context API approach allows us to:

  • Share loading states across components without prop drilling
  • Provide a centralized way to manage loading indicators at page/app level
  • Maintain performance by avoiding unnecessary re-renders in the component tree

State shape and normalization strategy

interface LoadingState {
  isLoading: boolean;
  loadingType?: 'skeleton' | 'spinner' | 'placeholder';
  items: Array<{
    id: string;
    status: 'pending' | 'loading' | 'loaded' | 'error';
  }>;
}

interface SkeletonState {
  visible: boolean;
  type: 'text' | 'image' | 'card' | 'list';
  count: number;
  animation: 'pulse' | 'wave';
}

Server state vs. client state separation

The skeleton loading component primarily deals with client-side rendering state:

  • Client state: visibility, animation type, item counts
  • Server state: actual data loading status (handled by API integration layer)

Cache invalidation strategy

Skeletons don't require cache invalidation since they're purely presentational. However, when data is fetched and displayed, the skeleton state should be updated to reflect the new content.

Optimistic updates design

Skeletons don't participate in optimistic updates directly, but the loading state should be updated optimistically when a component is expected to receive data soon.

Critical-path hook implementation

// src/hooks/useSkeletonLoading.ts
import { useState, useEffect, useContext } from 'react';
import { LoadingContext } from '../context/LoadingContext';

interface SkeletonHookState {
  isLoading: boolean;
  showSkeleton: boolean;
  skeletonType: 'text' | 'image' | 'card' | 'list';
  animation: 'pulse' | 'wave';
  count: number;
}

interface SkeletonHookReturn {
  state: SkeletonHookState;
  show: () => void;
  hide: () => void;
  setSkeletonType: (type: 'text' | 'image' | 'card' | 'list') => void;
  setAnimation: (animation: 'pulse' | 'wave') => void;
  setCount: (count: number) => void;
}

const useSkeletonLoading = (): SkeletonHookReturn => {
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [showSkeleton, setShowSkeleton] = useState<boolean>(false);
  const [skeletonType, setSkeletonType] = useState<'text' | 'image' | 'card' | 'list'>('card');
  const [animation, setAnimation] = useState<'pulse' | 'wave'>('pulse');
  const [count, setCount] = useState<number>(1);
  
  const loadingContext = useContext(LoadingContext);

  const show = () => {
    setShowSkeleton(true);
    setIsLoading(true);
  };

  const hide = () => {
    setShowSkeleton(false);
    setIsLoading(false);
  };

  // Handle global loading state coordination
  useEffect(() => {
    if (loadingContext?.isLoading) {
      setShowSkeleton(true);
      setIsLoading(true);
    } else if (!loadingContext?.isLoading && showSkeleton) {
      setShowSkeleton(false);
      setIsLoading(false);
    }
  }, [loadingContext?.isLoading, showSkeleton]);

  return {
    state: {
      isLoading,
      showSkeleton,
      skeletonType,
      animation,
      count
    },
    show,
    hide,
    setSkeletonType,
    setAnimation,
    setCount
  };
};

export default useSkeletonLoading;

The hook returns a comprehensive state object with:

  • isLoading: Boolean indicating whether loading is active
  • showSkeleton: Boolean controlling skeleton visibility
  • skeletonType: Type of skeleton to render (text, image, card, list)
  • animation: Animation style (pulse or wave)
  • count: Number of skeleton items to render

Key state transitions include:

  1. show() → sets loading and visibility flags to true
  2. hide() → sets loading and visibility flags to false
  3. Context-based updates when global loading state changes

Side-effect handlers manage:

  • Context synchronization for app-level loading coordination
  • State cleanup when component unmounts

The hook provides a clean abstraction for managing skeleton loading states while allowing flexibility for both local and global loading patterns.

4. Routing & Navigation

Route Structure and Hierarchy

The Skeleton Loading feature is a utility component that can be used across various routes in the application. It should not have its own dedicated route but rather be integrated into existing route structures where loading states are needed.

/
├── dashboard/
│   ├── overview
│   └── analytics
├── profile/
│   ├── settings
│   └── preferences
├── products/
│   ├── list
│   └── detail/:id
└── auth/
    ├── login
    └── signup

Code Splitting Strategy

Since Skeleton Loading is a utility component, it should be imported directly rather than split into separate bundles. However, any routes that use skeleton loading (like product detail pages) should implement route-based code splitting:

  • Product Detail Route: Split the detail view component using React.lazy to reduce initial bundle size
  • Dashboard Routes: Use component-based splitting for heavy visualization components

The Skeleton Loading component itself does not require authentication guards as it's a presentational utility. However, when used in protected routes:

  • Authentication status should be checked before rendering skeleton states
  • Navigation to protected pages should redirect authenticated users appropriately

Deep Linking Support and URL Parameter Design

The component does not handle URL parameters directly. When used in routes that support deep linking:

  • URL parameters should be preserved during skeleton loading states
  • Route parameters (e.g., :id) should be maintained in the URL even when skeleton is displayed

Browser History Management and Scroll Restoration

  • Scroll Restoration: When navigating between routes that use skeleton loading, scroll position should be preserved using react-router's built-in scroll restoration or custom implementation
  • History Management: The component should not interfere with browser history stack; it's purely a visual indicator during data fetching
// Example route configuration with skeleton usage
const routes = [
  {
    path: "/products/:id",
    element: lazy(() => import("./ProductDetail")),
    loader: productLoader,
    shouldRenderSkeleton: true
  },
  {
    path: "/dashboard",
    element: lazy(() => import("./Dashboard")),
    loader: dashboardLoader,
    shouldRenderSkeleton: true
  }
];

Route-Based Code Splitting Implementation

// Route configuration with lazy loading and skeleton support
import { lazy, Suspense } from 'react';
import { RouteObject } from 'react-router-dom';

const routes: RouteObject[] = [
  {
    path: "/products/:id",
    element: (
      <Suspense fallback={<SkeletonLoading type="product" />}>
        <ProductDetail />
      </Suspense>
    ),
    loader: productLoader,
  },
  {
    path: "/dashboard",
    element: (
      <Suspense fallback={<SkeletonLoading type="dashboard" />}>
        <Dashboard />
      </Suspense>
    ),
    loader: dashboardLoader,
  }
];

The skeleton loading component is designed to be used as a fallback in Suspense boundaries rather than as a standalone route, ensuring optimal performance and proper integration with React's rendering lifecycle.

5. API Integration Layer

API client design pattern

We'll implement a custom fetch wrapper with axios as the underlying HTTP client to provide consistent request/response handling and error management across our application. This approach offers better type safety, interceptors support, and cleaner separation of concerns compared to raw fetch or vanilla axios usage.

// api/client.ts
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';

const createApiClient = (): AxiosInstance => {
  const client = axios.create({
    baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'https://api.example.com',
    timeout: 10000,
    headers: {
      'Content-Type': 'application/json',
    },
  });

  // Request interceptor for adding auth tokens
  client.interceptors.request.use(
    (config) => {
      const token = localStorage.getItem('authToken');
      if (token) {
        config.headers.Authorization = `Bearer ${token}`;
      }
      return config;
    },
    (error) => Promise.reject(error)
  );

  // Response interceptor for handling errors globally
  client.interceptors.response.use(
    (response) => response,
    (error) => {
      if (error.response?.status === 401) {
        // Handle unauthorized access
        localStorage.removeItem('authToken');
        window.location.href = '/login';
      }
      return Promise.reject(error);
    }
  );

  return client;
};

export const apiClient = createApiClient();

Request/response interceptors

The request interceptor adds authentication tokens to all outgoing requests, while the response interceptor handles global error states like 401 Unauthorized by redirecting to login. These interceptors provide centralized control over HTTP behavior without requiring repetitive code in each API call.

Error handling and retry strategy

For robust error handling, we implement a retry mechanism with exponential backoff for transient network errors (5xx status codes) and client errors (400-499). For network failures, we show user-friendly error messages with recovery options.

// api/utils.ts
export const handleApiError = (error: any): string => {
  if (error.response) {
    const { status, data } = error.response;
    switch (status) {
      case 401:
        return 'Authentication required. Please log in.';
      case 403:
        return 'Access forbidden. You do not have permission to perform this action.';
      case 404:
        return 'Resource not found.';
      case 500:
        return 'Internal server error. Please try again later.';
      default:
        return data?.message || `HTTP ${status}: ${error.message}`;
    }
  } else if (error.request) {
    return 'Network error. Please check your connection and try again.';
  } else {
    return `Request error: ${error.message}`;
  }
};

export const retryWithBackoff = async (
  fn: () => Promise<any>,
  retries = 3,
  delay = 1000
): Promise<any> => {
  try {
    return await fn();
  } catch (error) {
    if (retries > 0 && [500, 502, 503, 504].includes(error.response?.status)) {
      await new Promise((resolve) => setTimeout(resolve, delay));
      return retryWithBackoff(fn, retries - 1, delay * 2);
    }
    throw error;
  }
};

Loading, error, and empty states design

We implement a consistent state management pattern for API interactions:

  1. Loading State: Display skeleton loading components while data is being fetched
  2. Error State: Show user-friendly error messages with retry options
  3. Empty State: Handle cases where API returns no data gracefully

Data fetching strategy

For skeleton loading components, we use client-side rendering (CSR) since these are primarily UI enhancements that don't benefit from server-side rendering. The skeleton loading is designed to be immediately visible to users while data loads in the background.

API response transformation layer

We implement a DTO-to-domain model mapping layer to normalize API responses:

// api/types.ts
export interface UserDto {
  id: string;
  name: string;
  email: string;
  avatar?: string;
}

export interface User {
  id: string;
  name: string;
  email: string;
  avatarUrl?: string;
}

export const transformUserDto = (dto: UserDto): User => ({
  id: dto.id,
  name: dto.name,
  email: dto.email,
  avatarUrl: dto.avatar,
});

Sequence diagram for primary data flows

sequenceDiagram
    participant U as User
    participant C as Component
    participant A as API Client
    participant S as Skeleton Loading

    U->>C: Component mounts or data refreshes
    C->>S: Render skeleton loading UI
    C->>A: Fetch data from API
    A-->>C: Response received (success/error)
    C->>S: Hide skeleton, show actual data

This sequence demonstrates how the skeleton loading component provides immediate feedback to users while data is being fetched from the API, creating a better perceived performance and user experience.

6. Performance Design

Core Web Vitals targets (LCP, INP, CLS) with specific numbers

  • Largest Contentful Paint (LCP): <2.5s for 90% of users
  • Interaction to Next Paint (INP): <200ms for 90% of interactions
  • Cumulative Layout Shift (CLS): <0.1 for 90% of users

Bundle size budget per route

  • Skeleton loading component: <2KB (minified + gzipped)
  • Supporting utilities and helpers: <1KB total
  • No additional dependencies beyond core React ecosystem

Critical rendering path optimization

  • Skeleton component must be render-blocking for initial paint
  • CSS must be inlined or loaded with high priority to avoid FOUC
  • Critical path JS should be minimal and bundled with main app chunk
  • No external resources required for skeleton rendering (no font loading, no image dependencies)

Image optimization strategy (formats, lazy loading, responsive images)

  • Skeletons do not use image assets - they are pure CSS/HTML
  • When image placeholders are needed, use SVG-based skeletons with appropriate aspect ratios
  • No lazy loading required as skeleton is rendered immediately

Font loading strategy

  • Skeletons should not depend on custom fonts for rendering
  • Use system font stack to ensure immediate availability
  • Critical CSS should include font fallbacks for skeleton elements

Memoization strategy (useMemo, useCallback, React.memo) — what to memoize and what not to

  • React.memo on skeleton container components with stable props
  • useMemo for computed styles and dimensions (e.g., calculating aspect ratios)
  • useCallback for event handlers that are passed down to child components
  • Avoid memoizing individual skeleton elements unless they have complex computed props

Virtualization for large lists (react-window / react-virtual)

  • Not applicable for skeleton loading as it's designed for single or small groups of elements
  • For lists of content with skeletons, use react-window or similar virtualization libraries for actual content rendering
  • Skeletons themselves should be lightweight and not require virtualization

Prefetching and preloading strategy

  • No prefetching required for skeleton components themselves
  • Skeletons should be available immediately on component mount
  • When content is being fetched, skeleton should appear before any content rendering begins
  • Preload critical CSS for skeleton styles in HTML head
// Example of performance-optimized skeleton component with memoization
import React, { memo, useMemo } from 'react';

interface SkeletonProps {
  width?: string | number;
  height?: string | number;
  variant?: 'text' | 'rect' | 'circle';
  count?: number;
  animation?: boolean;
}

const Skeleton = memo<SkeletonProps>(({ 
  width = '100%', 
  height = 20,
  variant = 'rect',
  count = 1,
  animation = true
}) => {
  const skeletonElements = useMemo(() => {
    return Array.from({ length: count }, (_, i) => (
      <div
        key={i}
        className={`skeleton-${variant}`}
        style={{
          width,
          height,
          animation: animation ? 'skeleton-pulse 1.5s infinite' : 'none'
        }}
      />
    ));
  }, [width, height, variant, count, animation]);

  return <div className="skeleton-container">{skeletonElements}</div>;
});

export default Skeleton;

Bundle size considerations for performance

  • Minimize CSS class names and avoid utility-first approaches for skeleton styles
  • Use CSS variables for consistent theming without increasing bundle size
  • Inline critical skeleton styles to avoid additional HTTP requests
  • Avoid CSS-in-JS for skeleton components to reduce runtime overhead

Rendering performance considerations

  • Skeletons should render synchronously with no async delays
  • Use CSS animations instead of JavaScript-based animation for better performance
  • Avoid complex DOM structures in skeleton elements to maintain low rendering cost
  • Ensure skeleton elements are lightweight and do not trigger layout thrashing

7. Error Handling & Resilience

Error Boundary Placement Strategy

Error boundaries should be strategically placed to prevent cascading failures while maintaining user experience. For skeleton loading, the primary error boundary should wrap the entire skeleton container to catch rendering issues from child components that might fail during loading states.

graph TD
    A[App] --> B[ErrorBoundary]
    B --> C[SkeletonContainer]
    C --> D[ContentComponent]
    C --> E[LoadingIndicator]
    C --> F[ErrorFallback]

Global Error Handler Design

Implement a global error handler that captures unhandled promise rejections and synchronous errors:

// GlobalErrorHandler.tsx
import React from 'react';

interface ErrorBoundaryState {
  hasError: boolean;
  error?: Error;
  errorInfo?: React.ErrorInfo;
}

class GlobalErrorHandler extends React.Component<{}, ErrorBoundaryState> {
  constructor(props: {}) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('Global error caught:', error, errorInfo);
    // Report to monitoring service
    this.reportError(error, errorInfo);
  }

  reportError = (error: Error, errorInfo: React.ErrorInfo) => {
    // Send to Sentry or similar monitoring service
    if (window.Sentry) {
      window.Sentry.captureException(error, {
        contexts: { react: errorInfo }
      });
    }
  };

  render() {
    if (this.state.hasError) {
      return <ErrorFallback />;
    }
    return this.props.children;
  }
}

export default GlobalErrorHandler;

Offline / Network Failure Handling

Implement graceful degradation for network failures:

// useNetworkStatus.ts
import { useState, useEffect } from 'react';

export const useNetworkStatus = () => {
  const [isOnline, setIsOnline] = useState<boolean>(true);
  const [connectionSpeed, setConnectionSpeed] = useState<'slow' | 'fast' | 'unknown'>('unknown');

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    // Check connection speed
    const checkConnectionSpeed = () => {
      if ('connection' in navigator) {
        const connection = (navigator as any).connection;
        if (connection.effectiveType === 'slow-2g' || connection.effectiveType === '2g') {
          setConnectionSpeed('slow');
        } else if (connection.effectiveType === '3g') {
          setConnectionSpeed('fast');
        }
      }
    };

    checkConnectionSpeed();
    window.addEventListener('online', checkConnectionSpeed);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
      window.removeEventListener('online', checkConnectionSpeed);
    };
  }, []);

  return { isOnline, connectionSpeed };
};

Graceful Degradation Patterns

When network issues occur or skeleton loading fails, degrade gracefully:

// SkeletonWithFallback.tsx
import React, { useState, useEffect } from 'react';
import { useNetworkStatus } from './useNetworkStatus';

interface SkeletonWithFallbackProps {
  children: React.ReactNode;
  fallback?: React.ReactNode;
  loadingTimeout?: number;
}

const SkeletonWithFallback: React.FC<SkeletonWithFallbackProps> = ({
  children,
  fallback = <div className="skeleton-fallback">Content failed to load</div>,
  loadingTimeout = 5000
}) => {
  const [isLoading, setIsLoading] = useState(true);
  const [hasError, setHasError] = useState(false);
  const { isOnline } = useNetworkStatus();
  
  useEffect(() => {
    if (!isOnline) {
      setHasError(true);
      return;
    }

    const timeoutId = setTimeout(() => {
      setIsLoading(false);
    }, loadingTimeout);

    return () => clearTimeout(timeoutId);
  }, [isOnline, loadingTimeout]);

  if (hasError) {
    return fallback;
  }

  if (isLoading) {
    return <SkeletonLoader />;
  }

  return <>{children}</>;
};

export default SkeletonWithFallback;

User Feedback on Errors

Implement comprehensive error feedback:

// ErrorToast.tsx
import React from 'react';
import { ToastContainer, toast } from 'react-toastify';

interface ErrorToastProps {
  error: Error;
  retryAction?: () => void;
}

const ErrorToast: React.FC<ErrorToastProps> = ({ error, retryAction }) => {
  const showError = () => {
    toast.error(
      <div>
        <p className="font-bold">Something went wrong</p>
        <p className="text-sm">{error.message}</p>
        {retryAction && (
          <button 
            onClick={retryAction}
            className="mt-2 px-3 py-1 bg-blue-500 text-white rounded text-sm"
          >
            Retry
          </button>
        )}
      </div>,
      {
        position: "bottom-right",
        autoClose: 5000,
        hideProgressBar: false,
        closeOnClick: true,
        pauseOnHover: true,
        draggable: true,
      }
    );
  };

  return (
    <ToastContainer 
      toastClassName="bg-red-50 border border-red-200 text-red-800"
      bodyClassName="p-4"
    />
  );
};

export default ErrorToast;

Error Fallback UI

Create a standardized fallback component:

// ErrorFallback.tsx
import React from 'react';

interface ErrorFallbackProps {
  error?: Error;
  resetErrorBoundary?: () => void;
}

const ErrorFallback: React.FC<ErrorFallbackProps> = ({ error, resetErrorBoundary }) => {
  return (
    <div className="flex flex-col items-center justify-center p-8 bg-gray-50 rounded-lg border border-gray-200">
      <div className="text-red-500 mb-4">
        <svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 2h13.856c1.584 0 2.938-1.354 2.938-3V7c0-1.659-1.341-3-3-3H5c-1.659 0-3 1.341-3 3v7c0 1.659 1.341 3 3 3z" />
        </svg>
      </div>
      <h2 className="text-xl font-semibold text-gray-800 mb-2">Something went wrong</h2>
      <p className="text-gray-600 mb-4 text-center">
        {error?.message || "Failed to load content"}
      </p>
      <button
        onClick={resetErrorBoundary}
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
      >
        Try Again
      </button>
    </div>
  );
};

export default ErrorFallback;

8. Testing Strategy

Unit Testing

  • Component tests: 90%+ coverage for all skeleton variants (text, image, card, list) using React Testing Library
  • Custom hook tests: 100% coverage for useSkeletonState hook with various loading states and transitions
  • Utility function tests: 100% coverage for animation helper functions and theme integration utilities
  • Props validation tests: Ensure all prop combinations render correctly without runtime errors

Integration Testing Strategy

  • State transition integration: Test that skeleton states properly transition from loading → loaded with appropriate content
  • Theme context integration: Verify skeleton components respect theme provider changes (dark mode, custom colors)
  • Layout boundary tests: Ensure skeletons properly handle container size changes and responsive breakpoints
  • Nested component integration: Test skeleton behavior within complex layouts like grids, lists, and modals

E2E Testing Scope

  • Playwright for end-to-end testing covering:
    • Loading state persistence during API fetch cycles
    • Skeleton appearance/disappearance timing with realistic network conditions
    • Responsive behavior across device sizes (mobile, tablet, desktop)
    • User interaction flow: skeleton → content transition during page navigation
  • Critical user journeys:
    • First load with network delay → skeleton → content
    • Page refresh → skeleton → content
    • Navigation between routes with skeleton states

Visual Regression Testing

  • Storybook Chromatic for capturing visual differences across:
    • All skeleton variants (text, image, card, list)
    • Different theme contexts (light/dark/contrast modes)
    • Responsive breakpoints (mobile, tablet, desktop)
    • Animation states and timing variations

Accessibility Testing Automation

  • axe-core integration in component tests to verify:
    • Proper ARIA attributes for screen readers (role="skeleton", aria-live="polite")
    • Keyboard navigation compatibility with skeleton states
    • Focus management when content loads and replaces skeletons
    • Color contrast compliance in all theme variants

Test Data Strategy and Fixtures

  • Static fixtures: Predefined skeleton configuration objects for consistent testing across environments
  • Dynamic test data: Generated test content with varying lengths and structures to simulate real-world usage
  • Network simulation fixtures: Mock network delays (200ms, 500ms, 1000ms) to test skeleton responsiveness
  • Theme variant fixtures: Complete theme object snapshots for dark mode, high contrast, and custom color schemes

9. Accessibility (A11y)

WCAG 2.1 AA compliance checklist for this feature

The skeleton loading component must meet the following WCAG 2.1 AA requirements:

  • 1.1.1 Non-text Content (Level A): Skeletons should not be the only content conveying information, but they can serve as placeholders for content that will load. Ensure that screen readers announce the skeleton state appropriately.
  • 1.3.1 Info and Relationships (Level A): Skeletons should maintain semantic structure that matches the actual content once loaded, ensuring that screen readers understand content hierarchy and relationships.
  • 1.4.1 Use of Color (Level A): Skeletons should not rely solely on color to convey meaning. Ensure that the skeleton's animated state is perceivable through other means (e.g., motion or contrast).
  • 1.4.3 Contrast (Minimum) (Level AA): Ensure that the contrast ratio between skeleton elements and their background meets 4.5:1 for normal text.
  • 2.1.1 Keyboard (Level A): Skeletons should not interfere with keyboard navigation, and any interactive elements within the skeleton should be navigable via keyboard.
  • 2.4.3 Focus Order (Level A): Ensure that focus order is logical and preserved when content transitions from skeleton to actual content.
  • 3.1.1 Language of Page (Level A): If skeleton content includes text, ensure that the language is correctly identified and announced by screen readers.

Keyboard navigation support

The skeleton loading component should not interfere with keyboard navigation. If the skeleton is part of a larger content area, it should:

  • Preserve the logical tab order when content transitions from skeleton to actual content.
  • Not introduce focus traps or prevent users from navigating to other elements in the DOM.
  • Allow keyboard users to skip over skeleton content if it's not interactive.

Focus management strategy

Focus should be managed carefully to ensure that:

  • When content transitions from skeleton to actual content, focus is restored to the appropriate element (e.g., the first interactive element in a newly loaded section).
  • If a skeleton is part of a form or list, focus should not be lost or misplaced during the loading state.
  • Focus is not trapped within the skeleton UI itself.

ARIA roles and attributes plan

The component should include appropriate ARIA attributes to enhance accessibility:

  • aria-busy="true" on the container when content is loading (to indicate that the content is in a transient state).
  • aria-live="polite" on containers with skeleton content to announce loading states to screen readers.
  • role="alert" or role="status" on loading indicators to provide context about the state change.
  • aria-label or aria-labelledby to describe the content that will be loaded (e.g., "Loading user profile...").
  • aria-describedby to provide more context if needed (e.g., "This section will load in a few seconds").

Screen reader compatibility

To ensure screen reader compatibility:

  • Use semantic HTML elements (<div>, <section>, <article>) with appropriate ARIA roles to structure content.
  • Announce loading states using aria-live regions to provide real-time updates for screen reader users.
  • Avoid relying on visual cues alone (e.g., animations) to indicate loading states — ensure that screen readers can understand and announce the state.
  • Ensure that the skeleton's structure matches the eventual content to avoid confusion or misinterpretation by screen readers.
// Example of accessible skeleton component with ARIA attributes
import React, { HTMLAttributes } from 'react';

interface SkeletonProps extends HTMLAttributes<HTMLDivElement> {
  /** Number of lines to render */
  lineCount?: number;
  /** Width of the skeleton (can be a percentage or pixel value) */
  width?: string | number;
  /** Height of the skeleton (can be a percentage or pixel value) */
  height?: string | number;
  /** Whether the skeleton is animated */
  animated?: boolean;
  /** Accessibility label for screen readers */
  ariaLabel?: string;
}

const Skeleton: React.FC<SkeletonProps> = ({
  lineCount = 3,
  width = '100%',
  height = '16px',
  animated = true,
  ariaLabel = 'Loading content...',
  ...rest
}) => {
  const skeletonLines = Array.from({ length: lineCount }, (_, i) => (
    <div
      key={i}
      className={`skeleton-line ${animated ? 'skeleton-animated' : ''}`}
      style={{ width, height }}
      aria-hidden="true"
    />
  ));

  return (
    <div
      className="skeleton-container"
      aria-live="polite"
      aria-label={ariaLabel}
      {...rest}
    >
      {skeletonLines}
    </div>
  );
};

export default Skeleton;

10. Security Considerations

XSS Prevention Strategy

The Skeleton Loading component must not render any user-provided content directly. All props and configuration values should be sanitized before processing:

// ❌ Vulnerable approach - DO NOT USE
const Skeleton = ({ content }: { content: string }) => (
  <div dangerouslySetInnerHTML={{ __html: content }} />
);

// ✅ Secure approach - sanitize all inputs
const Skeleton = ({ content }: { content: string }) => {
  const sanitizedContent = DOMPurify.sanitize(content);
  return <div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />;
};

CSRF Protection

When Skeleton components are used in forms or data submission contexts, ensure proper CSRF token handling through:

  • Token inclusion in request headers via interceptors
  • Component-level validation that prevents malformed requests
  • Secure cookie policies for session management

Content Security Policy (CSP) Headers

Implement strict CSP headers to prevent inline script execution:

Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'

Sensitive Data Handling in Frontend State

Skeleton components should never store or expose:

  • User credentials or tokens
  • Personal identifiable information (PII)
  • Session state data
  • API keys or secrets

Input Sanitization Approach

All component props should undergo sanitization:

interface SkeletonProps {
  // Validate and sanitize width/height values
  width?: string | number;
  height?: string | number;
  // Ensure only valid CSS units are accepted
  borderRadius?: string;
  // Validate animation type to prevent injection
  animationType?: 'pulse' | 'wave' | 'none';
}

const validateSkeletonProps = (props: SkeletonProps): SkeletonProps => {
  // Sanitize width/height to only allow valid CSS units
  const sanitizedWidth = typeof props.width === 'string' 
    ? props.width.match(/^[0-9]+(px|em|rem|%|vw|vh)$/)?.[0] || '100%'
    : `${props.width}px`;
    
  return {
    ...props,
    width: sanitizedWidth,
    height: typeof props.height === 'string' 
      ? props.height.match(/^[0-9]+(px|em|rem|%|vw|vh)$/)?.[0] || '100%'
      : `${props.height}px`
  };
};

Secure Prop Validation

Implement runtime prop validation to prevent malicious input:

const Skeleton = (props: SkeletonProps) => {
  const validatedProps = validateSkeletonProps(props);
  
  // Validate animation type
  if (!['pulse', 'wave', 'none'].includes(validatedProps.animationType || '')) {
    throw new Error('Invalid animation type provided');
  }
  
  return (
    <div 
      className="skeleton-loader"
      style={{
        width: validatedProps.width,
        height: validatedProps.height,
        borderRadius: validatedProps.borderRadius
      }}
    />
  );
};

Security Testing Considerations

  • Validate that no inline styles or scripts can be injected through props
  • Test with malicious input payloads to ensure proper sanitization
  • Verify that all DOM operations are safe and properly escaped
  • Ensure no cross-origin resource sharing vulnerabilities in loading states

11. Internationalization (i18n)

Translation Key Structure and Naming Convention

Following BEM-style naming with feature-scoped keys:

// Translation keys for skeleton loading component
{
  "skeleton": {
    "loading": "Loading...",
    "title": "Title",
    "description": "Description",
    "avatar": "Avatar",
    "button": "Button"
  }
}

RTL Support Considerations

RTL support is handled through CSS direction properties and layout adjustments:

  • All skeleton elements maintain consistent spacing regardless of text direction
  • Animation directions (e.g., shimmer) are mirrored in RTL contexts
  • Text alignment properties are dynamically adjusted via CSS classes

Date, Number, Currency Formatting Strategy

While skeleton loading doesn't directly handle date/number formatting, the component supports localized content patterns:

  • Content placeholders respect locale-specific width ratios (e.g., longer text in RTL languages)
  • Number placeholders adapt to locale-specific grouping separators
  • Currency symbols are handled at content level, not skeleton level

Locale Detection and Switching Strategy

The component leverages the existing i18n context and doesn't implement locale switching logic:

  • Uses useTranslation hook to access current locale
  • Respects existing translation key structure for content placeholders
  • Maintains consistent behavior across all supported locales (en, es, fr, ar, etc.)
// Example usage in i18n context
const { t, i18n } = useTranslation();
const currentLocale = i18n.language;

// Skeleton adapts to locale-specific content patterns
const skeletonContent = {
  title: t('skeleton.title'),
  description: t('skeleton.description')
};

The skeleton loading component is designed to be locale-agnostic in its core implementation, relying on the i18n system for content translation and layout adjustments. The component's visual behavior remains consistent across locales while respecting the underlying content structure and translation patterns.

12. Monitoring & Observability

Real User Monitoring (RUM) metrics to track

The Skeleton Loading component should contribute to key RUM metrics that measure perceived performance and user experience quality:

  • First Contentful Paint (FCP): Track how quickly meaningful content appears after initial render, which skeleton loading helps optimize
  • Largest Contentful Paint (LCP): Monitor when the largest content element becomes visible, ensuring skeleton loading doesn't delay actual content
  • Cumulative Layout Shift (CLS): Track layout shifts when skeleton elements are replaced with real content, ensuring proper animation timing and spacing
  • Time to Interactive (TTI): Measure when users can interact with content, ensuring skeleton loading doesn't block interactivity

Error tracking integration (Sentry / Datadog)

Implement error boundaries to capture any issues with skeleton loading components:

// Error boundary for skeleton components
class SkeletonErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // Log to monitoring service (Sentry/Datadog)
    console.error('Skeleton loading error:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <div className="error-fallback">Something went wrong with skeleton loading</div>;
    }
    return this.props.children;
  }
}

Custom events and user journey tracking

Track key user interactions with skeleton loading:

// Event tracking for skeleton loading behavior
const trackSkeletonEvents = {
  // Track when skeleton appears (user sees loading state)
  onSkeletonShow: (componentName: string, duration: number) => {
    // Send to analytics service
    window.gtag?.('event', 'skeleton_show', {
      component: componentName,
      duration_ms: duration,
      timestamp: Date.now()
    });
  },
  
  // Track when skeleton is replaced with real content
  onSkeletonHide: (componentName: string, duration: number) => {
    window.gtag?.('event', 'skeleton_hide', {
      component: name,
      duration_ms: duration,
      timestamp: Date.now()
    });
  }
};

Performance monitoring and regression detection

Set up performance baselines for skeleton loading:

// Performance monitoring hook
const useSkeletonPerformance = (componentName: string) => {
  const [startTime, setStartTime] = useState<number>(0);
  const [endTime, setEndTime] = useState<number>(0);

  useEffect(() => {
    setStartTime(Date.now());
    
    return () => {
      setEndTime(Date.now());
      // Report performance metrics to monitoring service
      const duration = endTime - startTime;
      if (duration > 2000) { // Log if loading takes more than 2 seconds
        console.warn(`Skeleton loading for ${componentName} took ${duration}ms`);
      }
    };
  }, [componentName]);

  return { startTime, endTime };
};

Key metrics to monitor:

  1. Skeleton appearance rate: Percentage of users who see skeleton loading
  2. Skeleton duration distribution: How long skeletons typically remain visible
  3. Content shift frequency: How often layout shifts occur when skeletons are replaced
  4. User interaction delay: Time between skeleton appearance and user ability to interact

Alerting thresholds:

  • High skeleton visibility: >50% of users see skeletons for more than 3 seconds
  • Layout shift incidents: >5% of page loads experience layout shifts during skeleton replacement
  • Performance degradation: 10% increase in FCP when skeletons are present vs. without

Integration with existing observability stack:

The component should integrate with existing monitoring tools through:

  • Custom metrics reporting to Prometheus/Grafana dashboards
  • Trace context propagation for distributed tracing
  • Error logging with structured error payloads containing component names and loading states

Data retention and privacy:

All monitoring data should:

  • Be anonymized to protect user privacy
  • Comply with GDPR/CCPA regulations
  • Have configurable opt-out mechanisms for users who prefer not to be tracked
  • Include data retention policies that align with company compliance requirements

Dashboard visualization:

Create dashboards showing:

  • Skeleton loading frequency by component type
  • Average skeleton duration per page route
  • User experience impact metrics (FCP/LCP correlation with skeleton usage)
  • Error rates and failure modes in skeleton loading systems

13. File & Folder Structure

graph TD
    A[frontend/] --> B[components/]
    A --> C[features/]
    A --> D[shared/]
    B --> E[Loading/]
    E --> F[Skeleton.tsx]
    E --> G[Skeleton.types.ts]
    E --> H[utils/]
    H --> I[skeletonUtils.ts]
    D --> J[ui/]
    J --> K[Button/]
    J --> L[Card/]
    D --> M[styles/]
    M --> N[animations.css]

Proposed Directory Structure

For a skeleton loading component, I recommend a feature-based folder structure to keep components and their related logic together:

frontend/
├── components/
│   └── Loading/
│       ├── Skeleton.tsx
│       ├── Skeleton.types.ts
│       └── utils/
│           └── skeletonUtils.ts
├── shared/
│   ├── ui/
│   │   ├── Button/
│   │   └── Card/
│   └── styles/
│       └── animations.css
└── features/
    └── dashboard/
        ├── Dashboard.tsx
        └── components/
            └── DashboardCard.tsx

Module Boundaries and Import Rules

  1. No cross-feature imports: Components in components/Loading should not import from features/ directly
  2. Barrel exports: Use barrel files for shared loading utilities:
    // components/Loading/index.ts
    export * from './Skeleton';
    export * from './Skeleton.types';
  3. Shared dependency isolation: Loading components should not depend on feature-specific logic

Shared vs Feature-Specific Code Separation

Shared code (in components/Loading/):

  • Core Skeleton component implementation
  • Type definitions and utility functions
  • Animation styles

Feature-specific code:

  • Usage patterns in feature components (e.g., DashboardCard.tsx)
  • Feature-specific loading configurations
  • Integration with feature's data fetching logic

The loading component is intentionally kept as a self-contained, reusable module that can be imported and configured by any feature without tight coupling to specific business logic.

14. Dependencies & Third-Party Libraries

Key Libraries Selected with Justification

1. React (v18+)

  • Justification: Core library for building user interfaces with component-based architecture. Essential for any modern React application.
  • Bundle Impact: ~150KB minified
  • Alternatives Considered: Preact - Smaller bundle size (~10KB), but requires more configuration for ecosystem compatibility.

2. Styled Components (v5+)

  • Justification: Provides CSS-in-JS with type-safe styling, better component encapsulation, and runtime performance through CSS variable generation.
  • Bundle Impact: ~15KB minified
  • Alternatives Considered: Emotion - Similar performance but slightly more complex API; CSS Modules - Less type safety and harder to maintain.

3. Framer Motion (v10+)

  • Justification: Excellent for creating performant animations and transitions with minimal code. Perfect for skeleton loading animations.
  • Bundle Impact: ~25KB minified
  • Alternatives Considered: React Spring - More complex API, heavier bundle size; CSS animations - Less flexible and harder to maintain.

4. React Hook Form (v7+)

  • Justification: For form-related skeleton loading patterns and validation handling. Not directly used in skeleton but part of broader form ecosystem.
  • Bundle Impact: ~15KB minified
  • Alternatives Considered: Formik - More verbose, less performant; Custom hooks - More boilerplate.

Alternatives Considered and Rejected

CSS-in-JS Libraries:

  • Styled Components vs. Emotion: Both provide similar functionality, but Styled Components has better TypeScript support and more predictable performance.
  • CSS Modules vs. Styled Components: CSS Modules lack runtime flexibility and make it harder to pass props to styles.

Animation Libraries:

  • React Spring vs. Framer Motion: React Spring is more powerful but requires more configuration and has higher bundle size (~50KB vs 25KB). Framer Motion offers better developer experience with similar performance.

Testing Libraries:

  • Jest vs. Vitest: Vitest offers better performance and TypeScript support but requires more setup for React testing.
  • React Testing Library vs. Enzyme: RTL provides better user-centric testing patterns and is more aligned with React best practices.

Licensing and Bundle Size Impact Assessment

License Compliance:

  • All dependencies are MIT licensed, ensuring no commercial restrictions or attribution requirements.
  • React and Styled Components have permissive open-source licenses compatible with enterprise usage.

Bundle Size Budget Analysis:

  • Total Dependency Bundle Size: ~205KB (minified + gzipped)
  • Budget Allocation:
    • Core React ecosystem: ~165KB (React + Styled Components + Framer Motion)
    • Supporting utilities: ~40KB (React Hook Form, additional helpers)
  • Performance Impact: Bundle size within acceptable limits for modern web applications (target < 300KB for initial load).

Third-Party Integration Considerations

TypeScript Support:

  • All selected libraries have excellent TypeScript support with comprehensive type definitions.
  • React Hook Form provides extensive type inference for form structures.

Ecosystem Compatibility:

  • Styled Components integrates well with React's context and state management patterns.
  • Framer Motion works seamlessly with React's animation lifecycle and component composition.

Performance Characteristics:

  • All libraries are optimized for production builds with tree-shaking support.
  • Framer Motion uses hardware acceleration and efficient animation rendering.

Maintainability:

  • Libraries have active maintenance cycles with regular updates.
  • Community support and documentation quality meet enterprise standards.

Security Considerations:

  • All dependencies are vetted for security vulnerabilities through automated scanning.
  • Regular security audits and patch management processes in place.

Build Tool Compatibility:

  • All libraries work with modern build tools (Vite, Webpack 5, Rollup).
  • No conflicts with existing bundler configurations or optimization strategies.

15. Open Questions, Risks & Trade-offs

Decisions that need product/engineering alignment

  • Animation duration and timing functions: Should skeleton animations use CSS transitions or custom timing functions? What's the acceptable range for animation duration (e.g., 0.5s-2s) to maintain perceived performance without feeling sluggish?
  • Skeleton content matching: Should skeleton placeholders exactly match the final content structure (same number of lines, same widths) or be more generic? This affects implementation complexity vs. perceived realism.
  • Accessibility impact: Should skeleton states be announced to screen readers? How do we balance performance with accessibility requirements?

Known trade-offs made in this design

  • Visual vs. functional fidelity: Prioritized performance and consistent loading patterns over pixel-perfect content matching, which would require more complex state tracking and potentially slower rendering
  • CSS-based vs. SVG-based approach: Chose CSS animations for better browser compatibility and smaller bundle size over more sophisticated animated SVGs that could provide more realistic loading effects
  • Simplicity vs. extensibility: Designed a minimal component API to reduce complexity, but this limits customization options for teams that might want more granular control over skeleton appearance

Risks and mitigations

  • Performance degradation: If too many skeleton components are rendered simultaneously, they could cause layout shifts or jank. Mitigation: Implement virtualization for lists and use CSS will-change properties
  • Accessibility compliance risk: If screen readers announce skeleton states, users might perceive content as missing or incomplete. Mitigation: Implement proper ARIA attributes and ensure screen reader compatibility through testing
  • Animation flickering: Fast content transitions might cause skeleton animations to appear briefly. Mitigation: Use CSS animation-play-state to pause animations when content is ready, and implement proper loading state management

Technical debt acknowledged

  • Limited customization: The current design assumes a uniform skeleton style across the application, which might not scale well for teams with multiple design systems or complex UI patterns
  • No dynamic sizing: The component currently doesn't support responsive skeleton sizes that adapt to different screen dimensions or content containers
  • Animation state management: The current approach uses CSS classes for animation states, which could become unwieldy as more complex loading patterns are introduced
  • Testing coverage gap: The component lacks comprehensive unit tests for edge cases like nested skeletons, mixed content types, and various animation states in different browser environments

Was this article helpful?

Topics in this article

More from Frontend LLD

Stay in the Loop

Get curated DSA patterns, coding problems, frontend and backend LLD/HLD, and AI/ML System Design insights delivered straight to your inbox. No spam, just focused interview and engineering content.