Skip to main content
CodeRocket
Performancehighloading

Show loading indicators

rule · loading-indicators

Loading indicators keep users informed during asynchronous operations.

Code Examples

TSX
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>
  )
}
CSS
@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

TSX
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>
  )
}
CSS
.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

TSX
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>
  )
}
CSS
.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

TSX
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

TSX
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

TSX
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

TSX
// 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

TSX
// 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