Skip to main content
Saved

Live Regions

Category Accessibility

Level Beginner

Examples

React, Vue, Native web

Announce dynamic content updates to screen readers using ARIA live regions without disrupting user flow.

In this article
  1. Problem
  2. Solution
  3. Example
  4. Basic Live Regions
  5. Form Validation Announcements
  6. Loading State Announcements
  7. Progress Indicator
  8. Timer/Countdown Announcement
  9. Search Results Announcement
  10. Auto-Save Status
  11. Screen Reader Only Class
  12. Benefits
  13. Tradeoffs
  14. Summary
  15. See also

Live Regions

Problem

A user clicks “Add to Cart” and a toast appears saying “Item added!”, but screen reader users hear nothing. The page didn’t reload, so the screen reader doesn’t announce the injected content, and users can’t tell whether their action worked.

I’ve tested e-commerce sites where cart updates were invisible to screen readers. Users would click add, assume it failed because they heard nothing, and end up adding the same item multiple times or giving up.

The same problem affects form validation: error messages appear inline but never get announced, so users submit invalid data repeatedly without knowing what’s wrong. Auto-save indicators that flash “Saved” go unannounced too.

Solution

Marking a region of the page with aria-live tells screen readers to announce changes to its content automatically.

aria-live="polite" suits non-urgent updates that can wait until the screen reader finishes its current announcement, such as “Item added to cart.” aria-live="assertive" is for urgent messages that should interrupt immediately, like errors blocking the user.

As shorthand, role="status" equals aria-live="polite" and role="alert" equals aria-live="assertive".

The live region container must exist in the DOM before you inject content. The usual approach is an empty <div role="status"> added on page load, with messages injected into it when events occur. Creating the region and content at the same time often fails because screen readers might miss the new region.

Example

These examples use live regions for success messages, error alerts, and progress updates that screen readers announce automatically.

Basic Live Regions

<!-- Polite: waits for current announcement to finish -->
<div aria-live="polite" aria-atomic="true" class="status-message"></div>

<!-- Assertive: interrupts immediately -->
<div aria-live="assertive" aria-atomic="true" class="error-message"></div>

Form Validation Announcements

Validation errors suit an assertive region, since users need to know what’s blocking submission:

function SignupForm() {
  const [errors, setErrors] = useState({});
  const [errorMessage, setErrorMessage] = useState('');

  const validateForm = (formData) => {
    const newErrors = {};
    if (!formData.email) newErrors.email = 'Email is required';
    if (!formData.password) newErrors.password = 'Password is required';
    setErrors(newErrors);

    const count = Object.keys(newErrors).length;
    setErrorMessage(count > 0 ? `Form has ${count} error${count > 1 ? 's' : ''}` : '');
    return count === 0;
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = Object.fromEntries(new FormData(e.target));
    if (validateForm(formData)) {
      // submit the form
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div role="alert" aria-live="assertive" className="sr-only">{errorMessage}</div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" aria-invalid={!!errors.email} />
        {errors.email && <p>{errors.email}</p>}
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" aria-invalid={!!errors.password} />
        {errors.password && <p>{errors.password}</p>}
      </div>
      <button type="submit">Sign Up</button>
    </form>
  );
}

Loading State Announcements

Announcing when loading starts and finishes tells users something is happening:

function DataLoader({ userId }) {
  const [data, setData] = useState(null);
  const [status, setStatus] = useState('');

  const loadData = async () => {
    setStatus('Loading...');
    try {
      setData(await fetchUser(userId));
      setStatus('Data loaded');
    } catch {
      setStatus('Failed to load');
    }
  };

  return (
    <>
      <button onClick={loadData}>Load Data</button>
      <div role="status" aria-live="polite" className="sr-only">{status}</div>
      {data && <UserProfile user={data} />}
    </>
  );
}

Progress Indicator

Announcing milestones rather than every percentage keeps the number of announcements manageable:

function FileUpload() {
  const [progress, setProgress] = useState(0);
  const [announcement, setAnnouncement] = useState('');

  const updateProgress = (percent) => {
    setProgress(percent);
    if (percent % 25 === 0) setAnnouncement(`Upload ${percent}% complete`);
  };

  return (
    <>
      <div role="progressbar" aria-label="Upload progress" aria-valuenow={progress} aria-valuemin="0" aria-valuemax="100">
        <div style={{ width: `${progress}%` }} />
      </div>
      <div role="status" aria-live="polite" className="sr-only">{announcement}</div>
    </>
  );
}

Timer/Countdown Announcement

The countdown announces key thresholds rather than every second:

function Countdown({ initialSeconds }) {
  const [seconds, setSeconds] = useState(initialSeconds);
  const [announcement, setAnnouncement] = useState('');

  useEffect(() => {
    const timer = setInterval(() => {
      setSeconds(prev => Math.max(prev - 1, 0));
    }, 1000);
    return () => clearInterval(timer);
  }, []);

  useEffect(() => {
    if (seconds === 60) setAnnouncement('1 minute remaining');
    else if (seconds === 10) setAnnouncement('10 seconds remaining');
    else if (seconds === 0) setAnnouncement('Time expired');
  }, [seconds]);

  return (
    <>
      <div>{seconds} seconds</div>
      <div role="status" aria-live="assertive" className="sr-only">{announcement}</div>
    </>
  );
}

Search Results Announcement

After a search, the region announces how many results were found:

function SearchResults() {
  const [results, setResults] = useState([]);
  const [announcement, setAnnouncement] = useState('');

  const search = async (query) => {
    setAnnouncement('Searching...');
    const data = await fetchResults(query);
    setResults(data);
    setAnnouncement(`${data.length} result${data.length !== 1 ? 's' : ''} found`);
  };

  return (
    <>
      <input type="search" onChange={(e) => search(e.target.value)} />
      <div role="status" aria-live="polite" className="sr-only">{announcement}</div>
      <ul>
        {results.map(r => <li key={r.id}>{r.title}</li>)}
      </ul>
    </>
  );
}

Auto-Save Status

Announcing the save status tells users their work has been saved:

function Editor() {
  const [content, setContent] = useState('');
  const [saveStatus, setSaveStatus] = useState('');

  const autoSave = useCallback(
    debounce(async (text) => {
      setSaveStatus('Saving...');
      try {
        await saveContent(text);
        setSaveStatus('Draft saved');
        setTimeout(() => setSaveStatus(''), 2000);
      } catch {
        setSaveStatus('Failed to save');
      }
    }, 1000),
    []
  );

  return (
    <>
      <textarea value={content} onChange={(e) => { setContent(e.target.value); autoSave(e.target.value); }} />
      <div role="status" aria-live="polite">{saveStatus}</div>
    </>
  );
}

Screen Reader Only Class

This utility class hides content visually while keeping it accessible to screen readers:

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

Benefits

  • Screen reader users hear feedback when content changes, making dynamic updates perceivable to everyone.
  • Messages announce automatically. Users don’t need to hunt through the page to find what changed.
  • Works with all major screen readers (NVDA, JAWS, VoiceOver) out of the box, with no special setup.
  • Loading states, progress milestones, and completion messages become audible as they happen.
  • Screen reader users get the same feedback that sighted users receive visually.

Tradeoffs

  • Too many announcements become audio spam and can make an app unusable for screen reader users, so live regions are best used sparingly.
  • The politeness level changes the behavior: assertive interrupts immediately and should be rare, while polite waits for a pause and should be your default.
  • The live region container must exist before content changes occur, which usually means adding it on page load and injecting messages later.
  • Rapid updates (stock prices, chat messages) can overwhelm screen readers, so announcements need throttling or batching.
  • Screen readers (NVDA, JAWS, VoiceOver) have different timing behaviors, so test across multiple tools.
  • Content must stay visible long enough to be announced. Clearing too quickly means users hear nothing.
  • aria-atomic="true" announces the entire region; false announces only changed content.
  • Automated testing can’t check whether announcements make sense, so you still need to test with a real screen reader.

Summary

Live regions announce dynamic content to screen reader users who can’t see visual updates. aria-live="polite" handles routine updates, and aria-live="assertive" is for the occasional critical alert. The region must exist in the DOM before content changes, and text updates trigger announcements.

See also

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.