Live Regions
Announce dynamic content updates to screen readers using ARIA live regions without disrupting user flow.
In this article
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> 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 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:
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 (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;falseannounces 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
- ARIA Roles and Attributes — Accessibility
- Loading State — Resilience
- Error Handling — Resilience
- ARIA Live Regions - MDN
- aria-live Attribute - W3C
- Dynamic Content Accessibility - W3C