PatternsAccessibility
ARIA Live Regions Advanced
Complex patterns for announcing dynamic content to screen readers
ARIA Live Regions announce dynamic content changes to screen readers without moving focus. Critical for SPAs, real-time updates, and async operations.
Live Region Basics
// Polite: Wait for user to finish
<div role="status" aria-live="polite">
Item added to cart
</div>
// Assertive: Interrupt immediately
<div role="alert" aria-live="assertive">
Error: Payment failed
</div>
// Off: Don't announce (default)
<div aria-live="off">
Not announced
</div>aria-live Politeness Levels
polite - Most Common
export function PoliteAnnouncement() {
const [message, setMessage] = useState('');
// Waits for user to pause
// Use for: success messages, loading complete, search results
return (
<>
<button onClick={() => setMessage('3 items found')}>
Search
</button>
<div role="status" aria-live="polite" aria-atomic="true">
{message}
</div>
</>
);
}assertive - Use Sparingly
export function AssertiveAnnouncement() {
const [error, setError] = useState('');
// Interrupts immediately
// Use for: errors, warnings, critical updates
return (
<>
<button onClick={() => setError('Payment failed! Try again')}>
Submit Payment
</button>
<div role="alert" aria-live="assertive">
{error}
</div>
</>
);
}aria-atomic
// Without aria-atomic: Only announces changed part
<div aria-live="polite" aria-atomic="false">
<span>Items:</span> <span>5</span>
</div>
// Announces: "5"
// With aria-atomic: Announces entire region
<div aria-live="polite" aria-atomic="true">
<span>Items:</span> <span>5</span>
</div>
// Announces: "Items: 5"aria-relevant
// Control what changes are announced
<div
aria-live="polite"
aria-relevant="additions removals text"
>
{/*
additions: New nodes announced
removals: Removed nodes announced
text: Text changes announced
all: Everything announced (default)
*/}
</div>
// Example: Only announce additions
<div aria-live="polite" aria-relevant="additions">
<ul>
{notifications.map(n => (
<li key={n.id}>{n.message}</li>
))}
</ul>
</div>Common Patterns
Form Validation
export function FormWithLiveValidation() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const validate = (value: string) => {
if (!value.includes('@')) {
setError('Please enter a valid email address');
} else {
setError('');
}
};
return (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
validate(e.target.value);
}}
aria-invalid={!!error}
aria-describedby={error ? 'email-error' : undefined}
/>
{/* Live region for errors */}
{error && (
<div
id="email-error"
role="alert"
aria-live="assertive"
>
{error}
</div>
)}
</div>
);
}Loading States
export function LoadingWithAnnouncement() {
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const loadData = async () => {
setLoading(true);
const result = await fetchData();
setData(result);
setLoading(false);
};
return (
<div>
<button onClick={loadData}>Load Data</button>
{/* Announce loading state */}
<div role="status" aria-live="polite" aria-atomic="true">
{loading && 'Loading data...'}
{!loading && data && 'Data loaded successfully'}
</div>
{data && <DataDisplay data={data} />}
</div>
);
}Search Results
export function SearchWithAnnouncement() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<any[]>([]);
useEffect(() => {
if (query) {
searchAPI(query).then(setResults);
}
}, [query]);
return (
<div>
<label htmlFor="search">Search</label>
<input
id="search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-describedby="search-results-count"
/>
{/* Announce result count */}
<div
id="search-results-count"
role="status"
aria-live="polite"
aria-atomic="true"
>
{results.length > 0 && `${results.length} results found`}
{query && results.length === 0 && 'No results found'}
</div>
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
</div>
);
}Timer/Countdown
export function AccessibleTimer() {
const [seconds, setSeconds] = useState(60);
const [announcement, setAnnouncement] = useState('');
useEffect(() => {
const timer = setInterval(() => {
setSeconds(prev => {
const next = prev - 1;
// Announce milestones
if (next === 30 || next === 10 || next === 0) {
setAnnouncement(`${next} seconds remaining`);
}
return next;
});
}, 1000);
return () => clearInterval(timer);
}, []);
return (
<div>
{/* Visual timer */}
<div>Time: {seconds}s</div>
{/* Announce milestones only */}
<div
role="timer"
aria-live="polite"
aria-atomic="true"
>
{announcement}
</div>
</div>
);
}Progress Updates
export function ProgressWithAnnouncement() {
const [progress, setProgress] = useState(0);
const [announcement, setAnnouncement] = useState('');
useEffect(() => {
// Update progress
const interval = setInterval(() => {
setProgress(prev => {
const next = Math.min(prev + 10, 100);
// Announce every 25%
if (next % 25 === 0) {
setAnnouncement(`${next}% complete`);
}
return next;
});
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div>
{/* Visual progress */}
<div
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Upload progress"
>
<div style={{ width: `${progress}%` }} />
</div>
{/* Announce milestones */}
<div role="status" aria-live="polite" aria-atomic="true">
{announcement}
</div>
</div>
);
}Reusable Live Region Hook
// hooks/useLiveAnnouncement.ts
import { useState, useEffect } from 'react';
export function useLiveAnnouncement(politeness: 'polite' | 'assertive' = 'polite') {
const [message, setMessage] = useState('');
const announce = (text: string, delay = 100) => {
// Clear first to ensure announcement
setMessage('');
setTimeout(() => {
setMessage(text);
}, delay);
};
return { message, announce, politeness };
}
// Usage
export function ComponentWithAnnouncement() {
const { message, announce, politeness } = useLiveAnnouncement('polite');
return (
<div>
<button onClick={() => announce('Action completed!')}>
Do Something
</button>
<div role="status" aria-live={politeness} aria-atomic="true">
{message}
</div>
</div>
);
}Advanced: Announcement Queue
// components/AnnouncementQueue.tsx
export function AnnouncementQueue() {
const [queue, setQueue] = useState<string[]>([]);
const [current, setCurrent] = useState('');
useEffect(() => {
// Process queue
if (queue.length > 0 && !current) {
const next = queue[0];
setCurrent(next);
setQueue(prev => prev.slice(1));
// Clear after announcement
setTimeout(() => setCurrent(''), 2000);
}
}, [queue, current]);
// Global announcement function
useEffect(() => {
(window as any).announce = (message: string) => {
setQueue(prev => [...prev, message]);
};
}, []);
return (
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{current}
</div>
);
}
// Usage anywhere in app
(window as any).announce('Item added to cart');Avoiding Over-Announcement
// ❌ BAD: Announces on every keystroke
export function BadSearch() {
const [results, setResults] = useState([]);
return (
<div>
<input onChange={(e) => search(e.target.value)} />
<div aria-live="polite">
{results.length} results
{/* Announces constantly! */}
</div>
</div>
);
}
// ✅ GOOD: Debounced announcement
export function GoodSearch() {
const [results, setResults] = useState([]);
const [announcement, setAnnouncement] = useState('');
const debouncedAnnounce = useMemo(
() => debounce((count: number) => {
setAnnouncement(`${count} results found`);
}, 1000),
[]
);
useEffect(() => {
debouncedAnnounce(results.length);
}, [results, debouncedAnnounce]);
return (
<div>
<input onChange={(e) => search(e.target.value)} />
<div role="status" aria-live="polite">
{announcement}
</div>
</div>
);
}Testing Live Regions
// Test with screen reader
describe('Live Region', () => {
it('announces messages', async () => {
render(<Component />);
// Trigger announcement
fireEvent.click(screen.getByText('Add to Cart'));
// Check live region updated
await waitFor(() => {
expect(screen.getByRole('status')).toHaveTextContent('Item added');
});
});
});
// Manual testing:
// 1. Enable screen reader (NVDA/VoiceOver)
// 2. Trigger action
// 3. Verify announcement heardBest Practices
- Use
role="status"for polite announcements - Use
role="alert"for assertive/errors - Keep messages short and clear
- Don't over-announce (debounce if needed)
- Use
aria-atomicfor complete context - Clear after announcing (optional)
- Test with real screen readers
- Announce only important changes
- Avoid announcement spam
- Queue announcements if many at once
Common Pitfalls
❌ Announcing every change: Overwhelming
✅ Only announce important updates
❌ Vague messages: "Updated"
✅ Specific messages: "5 items found"
❌ No aria-atomic: Incomplete context
✅ Use aria-atomic="true" for full message
❌ Assertive for everything: Annoying
✅ Polite by default, assertive for errors
❌ Not testing: Doesn't work
✅ Test with real screen readers
Live regions are powerful for announcing dynamic content—use them wisely and test thoroughly!