Load this skill whenever the project contains dynamic content updates, status messages, alerts, notifications, loading indicators, or any use of aria-live, role="status", role="alert", or role="log". Under no circumstances add or modify live-region markup without applying these rules. Prioritize correct politeness levels and avoid redundant announcements.
Install via CLI
openskills install mgifford/accessibility-skills---
name: aria-live-regions
description: >
Load this skill whenever the project contains dynamic content updates,
status messages, alerts, notifications, loading indicators, or any use of
aria-live, role="status", role="alert", or role="log". Under no circumstances
add or modify live-region markup without applying these rules. Prioritize
correct politeness levels and avoid redundant announcements.
---
# ARIA Live Regions Accessibility Skill
> **Canonical source**: `examples/ARIA_LIVE_REGIONS_BEST_PRACTICES.md` in `mgifford/ACCESSIBILITY.md`
> This skill is derived from that file. When in doubt, the example is authoritative.
Apply these rules when implementing any dynamic content updates announced to
assistive technologies.
**Only load this skill if the project contains dynamic content updates.**
---
## Core Mandate
ARIA live regions announce dynamic content changes to screen reader users who
would otherwise miss updates happening outside their current focus point.
They are powerful and frequently misused. The most common errors are:
1. Using `assertive` when `polite` is correct (interrupts the user)
2. Injecting the live region dynamically (AT misses the initial announcement)
3. Announcing too much or too little
4. Using live regions as a substitute for proper focus management
**Default to `polite`. Reach for `assertive` only when a delay would cause the
user to take a wrong action.**
---
## Severity Scale (this skill)
| Level | Meaning |
|---|---|
| **Critical** | Dynamic content change conveys no information to AT; user cannot complete task |
| **Serious** | `assertive` used for non-urgent updates, interrupting screen reader mid-sentence; live region injected dynamically and missed by AT |
| **Moderate** | Status updates absent; success/failure not announced; announcements too verbose |
| **Minor** | Redundant announcements; live region content duplicates visible text unnecessarily |
---
## Assistive Technology Context
Live region support varies significantly. Test with:
| AT | Browser | Behaviour |
|---|---|---|
| NVDA | Chrome | Reads `polite` at the end of the current utterance; `assertive` interrupts immediately |
| JAWS | Chrome | May buffer multiple rapid `polite` updates; reads them in sequence |
| VoiceOver | Safari (macOS) | `polite` queued after current speech; `assertive` interrupts |
| VoiceOver | Safari (iOS) | Similar to macOS; `assertive` may not interrupt reliably in all versions |
| TalkBack | Chrome (Android) | `polite` announced; `assertive` interrupts; test carefully |
| Voice Control | Any | Live regions not directly relevant to voice control navigation |
| Screen magnification | Any | Live regions may update off-screen; consider whether the user can see the update |
**Critical AT notes:**
- **NVDA+Firefox** historically had different live region behaviour than NVDA+Chrome — test both if your audience uses Firefox
- Live regions injected *after* page load but *before* content is added are sometimes missed — see the injection timing rule below
- Some AT reads `aria-live` content even when the region is visually hidden — this can cause duplicate announcements if used alongside visible text
---
## Critical: The Injection Timing Rule
**The live region element must be present in the DOM before content is inserted
into it.** This is the most common live region mistake.
```html
<!-- WRONG: injecting the live region dynamically — AT may miss the announcement -->
<script>
const region = document.createElement('div');
region.setAttribute('aria-live', 'polite');
region.textContent = 'Form submitted successfully.';
document.body.appendChild(region); // AT may not pick this up
</script>
<!-- RIGHT: live region in DOM on page load; content injected later -->
<div aria-live="polite" aria-atomic="true" class="visually-hidden" id="status">
<!-- Empty on load; JS inserts content here -->
</div>
```
```js
// Insert content into the pre-existing region
function announce(message) {
const region = document.getElementById('status');
region.textContent = ''; // Clear first
// Brief timeout ensures AT detects the change even when content is the same
setTimeout(() => { region.textContent = message; }, 50);
}
```
The 50ms timeout is a practical workaround for AT that only fires on content
change — if the message is the same as the previous one, clearing first and
re-inserting forces the change event.
---
## Critical: `polite` vs `assertive`
```
aria-live="polite" → waits for the user to finish their current action
aria-live="assertive" → interrupts immediately, even mid-sentence
```
**Use `polite` for:**
- Form submission success or failure
- Search results count updates ("12 results found")
- Cart update ("Item added to cart")
- Character count remaining ("140 characters left")
- Loading complete ("Results loaded")
- Filter/sort updates
**Use `assertive` only for:**
- Blocking errors that prevent task completion right now
- Timeout warnings where immediate action is required
- Security or session expiry alerts
**`assertive` used for non-urgent updates is Serious** — it interrupts screen
reader users in the middle of reading other content, and can make a page
unusable for heavy AT users.
---
## Serious: `role="status"` and `role="alert"` — The Shorthand
Two ARIA roles provide live region behaviour without explicit `aria-live`:
| Role | Implicit `aria-live` | Implicit `aria-atomic` | Use for |
|---|---|---|---|
| `role="status"` | `polite` | `true` | Success messages, status updates |
| `role="alert"` | `assertive` | `true` | Urgent errors, blocking messages |
```html
<!-- Status message — polite -->
<div role="status" class="visually-hidden" id="cart-status"></div>
<!-- Alert — assertive, use sparingly -->
<div role="alert" class="visually-hidden" id="session-warning"></div>
```
`role="alert"` is equivalent to `aria-live="assertive" aria-atomic="true"`.
Use it only when truly urgent. For form validation errors, prefer focus
management to an error summary (see `forms/SKILL.md`) — do not use `role="alert"`
on every field error.
---
## Serious: `aria-atomic` — Announce the Whole Region or Just the Change
```
aria-atomic="true" → announces the entire region content on any change
aria-atomic="false" → announces only the changed node (default)
```
For most status messages, `aria-atomic="true"` is correct — you want the
full message read, not just the changed word.
```html
<!-- Status: atomic=true ensures the whole message is read -->
<div role="status" aria-atomic="true" id="filter-status">
Showing 24 of 156 results for "accessible forms"
</div>
```
For a chat log or news feed where individual items are added, `aria-atomic="false"`
is correct — you want each new item announced individually.
```html
<!-- Chat: atomic=false announces each new message -->
<div aria-live="polite" aria-atomic="false" id="chat-log">
<p>Alex: Has anyone reviewed the pull request?</p>
<!-- New messages appended here -->
</div>
```
---
## Serious: `aria-relevant` — What Triggers an Announcement
```
aria-relevant="additions" → only new nodes (default)
aria-relevant="removals" → only removed nodes
aria-relevant="text" → text changes
aria-relevant="additions removals"→ both additions and removals
aria-relevant="all" → all changes
```
**Rarely override the default.** `additions` (the default) covers most use cases.
`aria-relevant="all"` announces every DOM change in the region, which is almost
always too verbose. Only use `removals` when a removal carries meaning (e.g.,
a notification dismissal that should be announced).
---
## Moderate: Visually Hidden Live Regions
Live regions that contain purely AT-facing announcements should be visually
hidden (not `display:none` — that removes them from the AT tree too):
```css
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
```
Never use `aria-hidden="true"` on a live region — it removes the region from
the AT tree and silences all announcements.
---
## Moderate: Common Correct Patterns
### Form submission feedback
```html
<!-- In DOM on page load -->
<div role="status" aria-atomic="true"
class="visually-hidden" id="form-feedback"></div>
<script>
form.addEventListener('submit', async (e) => {
e.preventDefault();
const result = await submitForm(e.target);
announce(result.ok ? 'Form submitted successfully.' : 'Submission failed. Check your entries.');
});
</script>
```
### Search results count
```html
<div role="status" aria-atomic="true"
class="visually-hidden" id="search-status"></div>
<script>
function updateResults(count, query) {
announce(`${count} results found for "${query}"`);
// Also update the visible count in the UI
}
</script>
```
### Loading state
```html
<div role="status" aria-atomic="true"
class="visually-hidden" id="loading-status"></div>
<script>
function startLoading() {
announce('Loading results…');
// Also show visible spinner
}
function doneLoading(count) {
announce(`${count} results loaded.`);
// Hide spinner
}
</script>
```
### Character count
```html
<textarea id="message" maxlength="280"
aria-describedby="char-count"></textarea>
<div id="char-count" aria-live="polite" aria-atomic="true">
<!-- Updated by JS -->
</div>
<script>
const textarea = document.getElementById('message');
const counter = document.getElementById('char-count');
textarea.addEventListener('input', () => {
const remaining = 280 - textarea.value.length;
// Only announce at thresholds to avoid constant interruption
if (remaining <= 20) {
counter.textContent = `${remaining} characters remaining`;
} else {
counter.textContent = ''; // Silent outside threshold
}
});
</script>
```
---
## Moderate: When NOT to Use Live Regions
Live regions are the wrong tool when focus management is available:
| Situation | Use instead |
|---|---|
| Form validation errors | Error summary with focus management (see `forms/SKILL.md`) |
| Dialog opening | Move focus into dialog |
| Page navigation in SPA | Move focus to `<h1>` or main content; announce page title |
| Accordion expanding | Move focus to expanded content |
| Toast/snackbar notifications | `role="status"` with `polite`; keep message brief |
Overusing live regions creates an announcement-heavy experience that exhausts
screen reader users. Prefer focus management where a natural focus destination exists.
---
## Moderate: Framework-Specific Timing Issues
In React, Vue, and Angular, state changes are asynchronous. The live region
may update before the DOM has settled, causing missed or duplicate announcements.
**React pattern:**
```jsx
// Use useEffect to announce after render
const [status, setStatus] = useState('');
useEffect(() => {
if (status) {
const region = document.getElementById('status-region');
region.textContent = '';
setTimeout(() => { region.textContent = status; }, 50);
}
}, [status]);
// Region in JSX — present on initial render
return (
<>
<div id="status-region" role="status" aria-atomic="true"
className="visually-hidden" />
{/* rest of component */}
</>
);
```
---
## Definition of Done Checklist
* [ ] Live region element present in DOM on initial page load (not injected dynamically)
* [ ] `aria-live="polite"` or `role="status"` used for non-urgent updates
* [ ] `aria-live="assertive"` or `role="alert"` used only for blocking, urgent messages
* [ ] `aria-atomic="true"` set on regions where the full message must be read
* [ ] `aria-relevant` not overridden unless the default is genuinely wrong
* [ ] Content cleared before re-inserting identical messages (with 50ms timeout)
* [ ] Visually hidden regions use `.visually-hidden` CSS, not `display:none` or `aria-hidden`
* [ ] Focus management used in preference to live regions where a focus destination exists
* [ ] Announcements concise — no verbose or repetitive text
* [ ] Form errors use error summary + focus management (not `role="alert"` per field)
* [ ] React/Vue/Angular: `useEffect` / `$nextTick` timing handled correctly
* [ ] Tested: NVDA+Chrome, JAWS+Chrome, VoiceOver+Safari
---
## Key WCAG Criteria
* 4.1.3 Status Messages (AA, WCAG 2.1) — **Critical if status updates not announced**
---
## References
* [Full best practices guide](https://github.com/mgifford/ACCESSIBILITY.md/blob/main/examples/ARIA_LIVE_REGIONS_BEST_PRACTICES.md)
* [Curated resources — ARIA live regions](../resources/by-topic/aria-live-regions.md)
* [MDN — ARIA live regions](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Guides/Live_regions)
* [WAI-ARIA spec — aria-live](https://www.w3.org/TR/wai-aria/#aria-live)
* [a11y-collective — ARIA live regions](https://www.a11y-collective.com/blog/aria-live/)
* [WCAG 2.2 Understanding 4.1.3 Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html)
* [ESDC — ARIA live regions (Government of Canada)](https://bati-itao.github.io/learning/esdc-self-paced-web-accessibility-course/module11/aria-live.html)
> **Standards horizon:** WCAG 3.0 is in development. Status message requirements
> are expected to be preserved and potentially expanded.
> Monitor: <https://www.w3.org/TR/wcag-3.0/>
No comments yet. Be the first to comment!