Front-end Engineering Lab

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 heard

Best Practices

  1. Use role="status" for polite announcements
  2. Use role="alert" for assertive/errors
  3. Keep messages short and clear
  4. Don't over-announce (debounce if needed)
  5. Use aria-atomic for complete context
  6. Clear after announcing (optional)
  7. Test with real screen readers
  8. Announce only important changes
  9. Avoid announcement spam
  10. 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!

On this page