Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.

Show loading indicators

Loading indicators provide feedback during asynchronous operations to keep users informed of progress.

Utilities
Quick take
Typical fix time 15 min
  • Show immediate feedback for all async operations
  • Use skeletons for content, spinners for actions
  • Include ARIA attributes for screen reader accessibility
  • Avoid content jumps when loading completes
Why it matters: Loading indicators reassure users that something is happening—without feedback, users may think the page is broken or click repeatedly, degrading experience and causing duplicate actions.

Rule Details

Loading indicators keep users informed during asynchronous operations.

Code Examples

function Spinner({ size = 24 }: { size?: number }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      className="animate-spin"
      role="status"
      aria-label="Loading"
    >
      <circle
        cx="12"
        cy="12"
        r="10"
        stroke="currentColor"
        strokeWidth="4"
        fill="none"
        opacity="0.25"
      />
      <path
        fill="currentColor"
        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
      />
    </svg>
  )
}
@keyframes spin {
  to { transform: rotate(360deg); }
}
 
.animate-spin {
  animation: spin 1s linear infinite;
}

Why It Matters

Loading indicators reassure users that something is happening—without feedback, users may think the page is broken or click repeatedly, degrading experience and causing duplicate actions.

Types of Loading Indicators

TypeBest ForDuration
SkeletonContent loading1-5 seconds
SpinnerButton actions0.5-3 seconds
Progress barFile uploads, long processes5+ seconds
ShimmerCards, lists1-3 seconds

Skeleton Loading

function SkeletonCard() {
  return (
    <div className="card" aria-busy="true" aria-label="Loading content">
      <div className="skeleton skeleton-image" />
      <div className="skeleton skeleton-title" />
      <div className="skeleton skeleton-text" />
      <div className="skeleton skeleton-text" style={{ width: '60%' }} />
    </div>
  )
}
 
function CardList({ isLoading, items }) {
  if (isLoading) {
    return (
      <div role="feed" aria-busy="true">
        {[1, 2, 3].map(i => <SkeletonCard key={i} />)}
      </div>
    )
  }
 
  return (
    <div role="feed" aria-busy="false">
      {items.map(item => <Card key={item.id} {...item} />)}
    </div>
  )
}
.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
}
 
.skeleton-image {
  height: 200px;
  width: 100%;
}
 
.skeleton-title {
  height: 24px;
  width: 80%;
  margin-top: 12px;
}
 
.skeleton-text {
  height: 16px;
  width: 100%;
  margin-top: 8px;
}
 
@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

Button Loading State

interface ButtonProps {
  children: React.ReactNode
  loading?: boolean
  onClick?: () => void
}
 
function Button({ children, loading, onClick }: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={loading}
      aria-busy={loading}
      className={`button ${loading ? 'button-loading' : ''}`}
    >
      {loading && <Spinner size={16} />}
      <span className={loading ? 'sr-only' : ''}>{children}</span>
      {loading && <span aria-live="polite">Processing...</span>}
    </button>
  )
}
.button {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 16px;
}
 
.button-loading {
  cursor: wait;
  opacity: 0.8;
}
 
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  border: 0;
}

Progress Bar

interface ProgressProps {
  value: number // 0-100
  label: string
}
 
function ProgressBar({ value, label }: ProgressProps) {
  return (
    <div
      role="progressbar"
      aria-valuenow={value}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-label={label}
    >
      <div className="progress-track">
        <div
          className="progress-fill"
          style={{ width: `${value}%` }}
        />
      </div>
      <span className="progress-text">{value}% complete</span>
    </div>
  )
}

React Suspense with Loading

import { Suspense } from 'react'
 
function ProductPage({ id }: { id: string }) {
  return (
    <div>
      <Suspense fallback={<ProductSkeleton />}>
        <ProductDetails id={id} />
      </Suspense>
 
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews id={id} />
      </Suspense>
    </div>
  )
}
 
// Loading state automatically shown while data fetches
async function ProductDetails({ id }: { id: string }) {
  const product = await getProduct(id)
  return <div>{product.name}</div>
}

Full Page Loading

function PageLoader() {
  return (
    <div
      className="page-loader"
      role="alert"
      aria-busy="true"
      aria-label="Page loading"
    >
      <Spinner size={48} />
      <p aria-live="polite">Loading page...</p>
    </div>
  )
}

Accessibility Requirements

// Always include ARIA attributes for screen readers
function AccessibleLoader({ isLoading, children }) {
  return (
    <div
      aria-busy={isLoading}
      aria-live="polite"
    >
      {isLoading ? (
        <div role="status">
          <Spinner />
          <span className="sr-only">Loading content, please wait</span>
        </div>
      ) : (
        children
      )}
    </div>
  )
}

Avoid Content Jumps

// Reserve space to prevent layout shift
function ContentWithLoader({ isLoading, content }) {
  return (
    <div style={{ minHeight: '200px' }}>
      {isLoading ? <SkeletonCard /> : <Card {...content} />}
    </div>
  )
}

Verification

Automated Checks

  • Test with slow network (3G throttling)

Manual Checks

  • Verify loading indicators appear immediately on action
  • Check ARIA attributes with screen reader
  • Verify no layout shift when content loads
  • Check focus management after loading completes

Use with AI

Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.

Check

Verify implementation

Verify that loading states are displayed during async operations with appropriate ARIA attributes for screen readers.

Fix

Auto-fix issues

Implement loading spinners/progress bars with aria-busy, aria-live, and clear visual feedback for ongoing operations.

Explain

Learn more

Explain how loading indicators improve perceived performance and accessibility by communicating system status to users.

Review

Code review

Review the routes, assets, and loading behavior that affect Show loading indicators. Flag exact files, requests, or rendering steps that add unnecessary network, CPU, or layout cost, and describe the measurement method used to confirm the issue.

Sources

References used to support the guidance in this rule.

Further Reading

Tools and supplementary material for exploring the topic in more depth.

PageSpeed Insights
pagespeed.web.devTool

Rules that often go hand-in-hand with this one.

Disable lazy loading for above-the-fold content

Detects lazy loading on likely above-fold images to improve Largest Contentful Paint (LCP)

Performance
Implement lazy loading for offscreen content

Images and heavy resources below the fold are lazy loaded to improve initial performance.

Performance
Provide an offline fallback page

When the network is unavailable, users are shown a custom offline fallback page rather than the browser's generic error screen.

Performance
Load non-critical code on user interaction

Defer JavaScript modules, widgets, and third-party code until the user signals intent through a click, focus, hover, or similar interaction.

Performance

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385