Show loading indicators
Loading indicators provide feedback during asynchronous operations to keep users informed of progress.
- 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
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
| Type | Best For | Duration |
|---|---|---|
| Skeleton | Content loading | 1-5 seconds |
| Spinner | Button actions | 0.5-3 seconds |
| Progress bar | File uploads, long processes | 5+ seconds |
| Shimmer | Cards, lists | 1-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.