Live Regions
Announce dynamic content updates to screen readers using ARIA live regions without disrupting user flow.
In this article
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 who heard nothing after clicking add assumed it had failed, and they ended 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
When you mark a region of your page with aria-live, screen readers 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. If you create the region and the content at the same time, it often fails, because screen readers might miss the new region.
Example
These examples use live regions for status updates and error alerts 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> function NotificationSystem() {
const [message, setMessage] = useState('');
const showSuccess = (text) => {
setMessage(text);
setTimeout(() => setMessage(''), 3000);
};
return (
<>
<button onClick={() => showSuccess('Item added to cart')}>Add to Cart</button>
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{message}
</div>
</>
);
} <template>
<button @click="addItem">Add to Cart</button>
<div role="status" aria-live="polite" aria-atomic="true" class="sr-only">
{{ statusMessage }}
</div>
</template>
<script setup>
import { ref } from 'vue';
const statusMessage = ref('');
const addItem = () => {
statusMessage.value = 'Item added to cart';
setTimeout(() => statusMessage.value = '', 3000);
};
</script> 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 are announced automatically. Users don’t need to hunt through the page to find what changed.
- Works with all the major screen readers, including NVDA, JAWS, and VoiceOver, out of the box, with no special setup.
- Loading states and progress milestones 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 you should use live regions sparingly.
- The politeness level changes the behavior:
assertiveinterrupts immediately and should be rare, whilepolitewaits 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, such as stock prices or chat messages, can overwhelm screen readers, so you’ll need to throttle or batch the announcements.
- NVDA, JAWS, and VoiceOver have different timing behaviors, so it’s worth testing with more than one of them.
- Content must stay visible long enough to be announced. If your code clears it too quickly, users hear nothing.
aria-atomic="true"announces the entire region, andfalseannounces only the 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
- ARIA Roles and Attributes — Accessibility
- Loading State — Resilience
- Error Handling — Resilience
- ARIA Live Regions - MDN
- aria-live Attribute - W3C
- Dynamic Content Accessibility - W3C