---
name: aria-live-announcements
description: "Audits and fixes how dynamic changes reach screen readers: toasts, form errors, saved and loading messages, search result counts, cart updates (WCAG 4.1.3). Finds live regions rendered conditionally in React, Vue or Angular, regions hidden with display:none, assertive overuse, too many regions and script-injected alerts, then watches a real page for regions inserted late. Covers role=status, role=alert, aria-live and ariaNotify. Use for 'screen reader does not announce' bugs. Do NOT use for focus management, accessible names, dialogs or screen reader testing."
license: CC-BY-4.0
metadata:
  author: Afeez Adebayo
  author_url: https://afeezadebayo.com
  author_linkedin: https://www.linkedin.com/in/afeez-adebayo/
  author_x: https://x.com/za_icon
  source: https://afeezadebayo.com/skills-md/frontend-development/accessibility/aria-live-announcements/
  category: frontend-development
  subcategory: accessibility
  version: "1.0.0"
  created: "2026-10-30"
  updated: "2026-10-30"
---

# ARIA Live Announcements: Make Status Messages Reach Screen Readers

An agent works on announcements in this order: list every message the interface shows without a page load, decide which ones need announcing, run a static checker over the source, run a browser probe that clicks through the triggers and reports what happened to the live regions, fix the structure first (a region that exists, stays in the accessibility tree and only changes its text), then the urgency, then repeats, and re-run both tools. The checker reads markup and component syntax. The probe shows the DOM behaviour. Neither can hear a screen reader.

Patterns and code are in `references/patterns.md`. Technique ids and support notes are in `references/appendix.md`.

## When to use this skill, and when not to

Use it when a screen reader user is not told about a saved, failed, loading, added or filtered result, when a toast is silent, or when a validator or audit flags status messages (SC 4.1.3).

| Request | Use instead |
|---|---|
| Moving focus to an error summary or a new heading | `keyboard-focus.md` |
| Names, roles and `aria-hidden` on controls | `aria-names-roles.md` |
| Native `<dialog>` and popover markup | `dialog-and-popover.md` |
| Field labels, error text wording, validation timing | `html-forms.md` |
| Real speech output, timing and verbosity | Out of scope. Needs a person with the screen reader |

## Guardrails

1. **Propose, then change.** Report and plan first. Edit only after the user approves a batch, on a branch under version control. Never push, merge or deploy.
2. **Pages you own only.** `live_probe.js` opens a page and clicks the selectors you give it. Run it only on pages the user owns, never on a page where a click can pay, send, delete or publish. Use a test environment or static copy with test data. Never ask for cookies, tokens or passwords.
3. **Data handled.** `live_check.py` reads the files you pass. `live_probe.js` prints element descriptions (tag, id, role) and counts, never page text. Messages can contain names, order numbers or health or payment details: do not paste message text, screenshots or form values into prompts or reports.
4. **Nothing is invented.** Every rule carries a source and a date. Version-dependent behaviour carries **verify before relying on this**.
5. **Not a conformance statement.** A clean run means the structure is sound. Whether a message is spoken, and when, depends on the screen reader and browser pairing. It is not a WCAG conformance claim and not legal advice. A conformance claim needs a person who has heard the page with a screen reader.
6. **Refuses to:** make a region assertive because a message matters to the team, announce every keystroke, put a live attribute on `main`, `form` or a long list, hide a region with `display: none`, or report a fix as verified without the probe.

## Inputs the agent must have before starting

Ask for every missing item. Do not assume.

| Input | Ask exactly |
|---|---|
| Messages | "List every message that appears without a page load: saved, error, added to cart, results count, loading, session timeout, toasts." |
| Source | "Give me the component or template files that render them." |
| Framework | "Which framework renders them: React, Vue, Angular, Svelte or plain HTML?" |
| Page | "Which page may I click through? Give a local file or a test URL you own, and a click selector for each trigger that is safe to press." |
| Screen readers | "Which screen reader and browser pairings must work? A person with the device has to confirm." |
| Tooling | "Is Node with Playwright installed? If not, may I install it in a temporary folder?" |
| Approval | "May I edit on a branch in batches and show each diff first?" |

## Workflow

### Step 1: Classify each message

W3C Understanding SC 4.1.3 (AA): a status message is a content change that is not a change of context and that reports success or results, a waiting state, progress or an error.

| Message | Needs a live region? |
|---|---|
| "Saved", "Added to cart", "18 results returned", "Loading" | Yes: status message |
| Validation error that stays on the page and focus does not move | Yes |
| Error summary that receives focus | No. A change of focus is a change of context, so it is not a status message |
| Content revealed by a menu, accordion, tab or tree | No. That is SC 4.1.2 (`aria-names-roles.md`) |
| The results list itself | No. Announce the count, not the list |

### Step 2: Run the static checker

```
python3 scripts/live_check.py src/Cart.jsx page.html | head -40
```

Read only its output. Do not read the script. The first line has the counts and the number of live regions found. `--strict` exits 1 on warnings.

| Code | Level | Meaning | Fix |
|---|---|---|---|
| `live-conditional` | error | Region rendered behind `&&`, a ternary, `v-if`, `*ngIf` or `@if`: it arrives with its text | Step 4 |
| `live-hidden` | error | Region hidden with `hidden`, `display:none`, `aria-hidden` or `v-show` | Step 4 |
| `live-injected` | warn | Region created by script with its text in one step | Step 4 |
| `live-conflict` | warn | `role=alert` with `aria-live=polite`, or `role=status` with `aria-live=assertive` | Step 5 |
| `live-container` | warn | Live attribute on `main`, `form`, `nav`, `table`, `section`, `article`, `ul`, `ol` or `aside` | Step 4 |
| `live-region-count` | warn | More than two regions in a file | Step 4 |
| `live-assertive-many` | warn | More than one assertive or alert region | Step 5 |
| `toast-no-role` | warn | Element named toast, snackbar, notification or similar with no live role | Step 4 |
| `live-redundant`, `live-weak-role`, `live-relevant`, `notify-used` | info | Repeated defaults, weakly supported roles or attributes, `ariaNotify` present | Steps 5 and 6 |
| `notify-no-fallback` | warn | `ariaNotify()` with no live region anywhere | Step 6 |

The checker reads text, not a DOM, and splits tags on `>`. Brace expressions that contain `>` can hide a tag. It cannot see a region built by a library. Treat a `live-conditional` hit that sits in a string or comment as a false positive after reading the line.

### Step 3: Probe the page

```
node scripts/live_probe.js page.html '#save' '#remove' '#pay'
```

It watches the DOM, clicks each selector in order, waits 700 ms after each, and prints one line: `regions-at-load=N changed=N late-inserted=N hidden-changed=N`, then one line per problem. Needs Node and Playwright (`require('playwright')` must resolve). Chromium only.

| Output | Meaning | Action |
|---|---|---|
| `late-inserted` | A region was added to the DOM already holding text | Step 4 |
| `hidden-changed` | Text changed inside a region that was hidden | Step 4 |
| `changed=0` after a click that should announce | The click changed no live region | The message goes elsewhere. Find the render path |
| `regions-at-load=0` | No region exists before the first action | Step 4 |

### Step 4: Fix the structure

Sources: htmhell (2023), dev.to (framework article), MDN, all fetched 30 October 2026.

| Rule | Fix |
|---|---|
| The region must exist in the DOM, empty, before the text changes | Put the empty element in the markup at load. Change its text, or add a child to it |
| Never toggle the region itself | React, Vue and Angular fixes: `references/patterns.md`, section 3 |
| Never hide the region | `display: none` and `visibility: hidden` remove it from the accessibility tree. Use the clipped `.visually-hidden` class (`references/patterns.md`, section 1) |
| Do not reveal a hidden region and change its text in the same moment | Keep it visible or clipped permanently |
| Keep regions small | A live attribute on a container re-reads every change inside it. Use a dedicated element |
| Use at most two regions | One polite, one assertive, reused through one helper (`references/patterns.md`, section 2) |
| Toast without a role | Give a non-urgent toast `role="status"`. A toast must not be the only channel for critical information (ibelick) |

### Step 5: Choose the urgency

| Message | Use | Why |
|---|---|---|
| Saved, added, results count, progress, filter applied | `role="status"` (polite) | Spoken at the next graceful moment (MDN) |
| Error the user must act on now, session about to expire | `role="alert"` (assertive) | Highest priority, may clear the speech queue (MDN) |
| Anything routine | Never assertive | An assertive update can cause a simultaneous polite one to be dropped (htmhell) |
| Time-limited content | Assertive, and a way to extend or turn it off | MDN, WCAG timing |
| Updates that change faster than a person can listen (scores, tickers, per-keystroke results) | `off`, or announce a summary after the user pauses | MDN, htmhell |

Do not add `aria-live` to `role="status"` or `role="alert"`: the role already implies `polite` or `assertive`, plus `aria-atomic="true"` (htmhell). Prefer `status` and `alert` over `log`, `timer` and `marquee`, which have weaker support (htmhell). `aria-busy`, `aria-relevant` and `aria-atomic` vary by pairing: verify before relying on them.

### Step 6: Repeats, timing and ariaNotify

| Case | Fix |
|---|---|
| The same message must be announced twice | Clear the text, wait, then set it again. Start at 150 ms and confirm with the target screen reader: mgifford uses 50 ms, the dev.to article suggests 150 to 500 ms. **Verify before relying on this** |
| A screen-reader-only notice stays in the DOM | Remove it about 500 ms after it appears, to avoid stale re-announcements (htmhell) |
| Several updates in one burst | Combine them into one message. `ariaNotify()` usually speaks only the latest call (MDN) |
| Browsers that support it | `document.ariaNotify(message, { priority })`, `normal` or `high`, with the live-region helper as the fallback in an `else` branch. MDN labels it Baseline 2026, newly available. **Verify before relying on this** |

### Step 7: Verify and report

1. Re-run `live_check.py`. Errors must be 0. Each warning is fixed or listed with a reason.
2. Re-run `live_probe.js` with the same selectors. `late-inserted` and `hidden-changed` must be 0 and `changed` must equal the number of triggers that announce.
3. List each message with its role and urgency. Do not paste the message text.
4. State what is untested: speech output on every pairing, verbosity, interrupted speech, voice control.
5. Ask a person to try each message with the target screen readers: JAWS with Chrome, NVDA with Firefox, Narrator with Edge, VoiceOver with Safari (htmhell).
6. Use the report template. Do not echo unchanged code back.

## Worked example

An invented page of 10 lines (`demo-live.html`): a Save button that writes text into `#ok` (`role="status"`, empty at load), a Remove button that writes into `#hid` (`role="status"`, `display:none`), and a Pay button that sets `innerHTML` to a new `<p role="alert">Payment failed</p>`.

```
$ python3 scripts/live_check.py demo-live.html
1 errors, 2 warnings, 0 info, 3 live regions
demo-live.html:5   error  live-hidden        <div> is hidden or toggled with display:none ...
demo-live.html:1   warn   live-region-count  3 live regions in this file ...
demo-live.html:10  warn   live-injected      <p> is created by script together with its text ...

$ node scripts/live_probe.js demo-live.html '#save' '#remove' '#pay'
regions-at-load=2 changed=1 late-inserted=1 hidden-changed=1
late-inserted  p[role=alert]  region added to the DOM already holding text: often not announced
hidden-changed  div#hid[role=status]  text changed while the region was hidden: not announced
```

(Messages shortened here.) Only the Save message follows the rules in step 4. The other two fall under the cases that step lists as unreliable, which is an inference from the sources and not a speech test. The probe finds what the checker cannot: the injected alert sits inside a script string, and the checker sees it only because the line contains `innerHTML`.

One batch: the hidden region goes, "Item removed" is written into `#ok`, the injected alert becomes an empty `<div id="err" role="alert">` in the markup and the handler sets its text. The re-run prints `0 errors, 0 warnings, 0 info, 2 live regions` and `regions-at-load=2 changed=2 late-inserted=0 hidden-changed=0`.

The same checker on a React cart (`{saved && <div role="status">Saved</div>}`, `{error ? <p role="alert" aria-live="polite">…}`, `<main aria-live="polite">`) printed `3 errors, 5 warnings, 1 info, 6 live regions`: two `live-conditional`, one `live-hidden`, and warnings for the container, the conflicting role and attribute, a toast with no role, region count and assertive count.

## Templates

### Report

```
Live announcements report: <page or repo>, <date>
Files: <list>. Probe browser: <Chromium version>. Framework: <name>.
live_check before: N errors, N warnings. After: N errors, N warnings.
live_probe before/after: regions-at-load N/N, changed N/N, late-inserted N/N, hidden-changed N/N.
Messages: <event  role  urgency  fix>   (one line each, no message text)
Left as is: <item  reason>
Untested: speech output on JAWS, NVDA, Narrator, VoiceOver, TalkBack; verbosity; voice control (edit to match)
Not a WCAG conformance claim. A person must listen with a screen reader.
```

### Starting prompt

```
Use the aria-live-announcements skill. Components: <paths>. Framework: <name>.
Messages: <list>. Page I own for the probe: <path or URL>. Safe click selectors: <list>.
Node and Playwright: <yes|no>. Edit on a branch in batches of one component and show each diff first.
```

## Failure modes and anti-patterns

| Mistake | How to spot it | Do instead |
|---|---|---|
| Region rendered only when there is a message | `live-conditional`, `late-inserted` | Always rendered, text changes (step 4) |
| Region hidden with `display: none` or `v-show` | `live-hidden`, `hidden-changed` | Clipped visually-hidden class (step 4) |
| `role="alert"` for routine messages | `live-assertive-many` | `role="status"` (step 5) |
| One live attribute on `main`, a form or a list | `live-container` | A small dedicated region (step 4) |
| Announcing every keystroke or result row | `changed` far above the number of triggers | One summary after the user pauses (step 5) |

## Quality bar

Run every item before handing over.

1. `live_check.py` errors are 0, and every warning and info line is fixed or listed with a reason.
2. `live_probe.js` ran with every safe trigger, with `late-inserted=0` and `hidden-changed=0`.
3. Every live region is in the markup at load and keeps its place in the DOM.
4. At most two regions exist, one polite and one assertive.
5. No routine message is assertive, and no region has both a role and a conflicting `aria-live`.
6. No live attribute sits on a container with content that changes for other reasons.
7. Messages that take focus are not duplicated in a live region.
8. The report lists what was not tested and states it is not a WCAG conformance claim.

## Cost

Figures are estimates from this skill's own count (words × 1.35 for prose, characters ÷ 3.5 for code and tables), not from a model tokeniser. Rival figures are estimates from the visible text of pages I read.

| Item | Tokens | Status | Read condition |
|---|---|---|---|
| A. Description (always loaded) | 110 | Estimate | Every session with the skill installed |
| B. `SKILL.md` body | 4,460 | Estimate | When the skill fires |
| C. `references/patterns.md` | 904 | Estimate | Step 4 for framework fixes, the announcer helper or the hidden-text CSS; step 6 for `ariaNotify` code |
| C. `references/appendix.md` | 729 | Estimate | Only for technique ids, support notes or the rival comparison |
| Script output (typical run) | 250 | Estimate | Checker and probe output measured on the worked example |
| D. Typical run (A + B + script output) | 4,820 | Estimate | Common path, no references |
| D. Worst case (A + B + both references + 40 lines of output) | 7,600 | Estimate | Large input, every branch |
| E. Cheaper path | 4,670 | Estimate | Run the checker, fix flagged lines, skip the probe detail and the references |

Closest rivals read about 175 to 2,430 tokens (estimates from visible text). The longest has no checker and no browser probe, and the others are shorter and give no code or thresholds. This skill costs more than the short ones to load and less than the long one, and it measures behaviour instead of asking the agent to judge it.

## Handoffs

`aria-names-roles.md` covers names, roles and `aria-hidden`. `keyboard-focus.md` covers focus moves. `html-forms.md` covers error wording and validation timing. `dialog-and-popover.md` covers native dialogs.

## Appendix

Code and framework fixes are in `references/patterns.md`. Technique ids, support notes and the rival comparison are in `references/appendix.md`. Read them only when a step points there.

## Sources

Last verified 30 October 2026 for every source.

- Anthropic, Skill authoring best practices: https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices
- W3C, Understanding SC 4.1.3 Status Messages: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html
- MDN, `aria-live`: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-live
- MDN, `Element.ariaNotify()`: https://developer.mozilla.org/en-US/docs/Web/API/Element/ariaNotify
- HTMHell Advent Calendar 2023, ARIA Live Regions: https://htmhell.dev/adventcalendar/2023/22/
- Dennis Koppenhagen, When your live region isn't live: fixing aria-live in Angular, React and Vue (DEV Community): https://dev.to/dkoppenhagen/when-your-live-region-isnt-live-fixing-aria-live-in-angular-react-and-vue-1g0j
- mgifford, aria-live-regions. Rival implementation: https://sharedcontext.ai/skills/external/mgifford/aria-live-regions
- community-access, live-region-controller. Rival implementation: https://www.skills.sh/community-access/accessibility-agents/live-region-controller
- thedaviddias, aria-live-regions (Tessl review). Rival implementation: https://tessl.io/registry/skills/github/thedaviddias/Front-End-Checklist/aria-live-regions/review
- ibelick, fixing-accessibility. Rival implementation: https://claudeskills.info/skills/ibelick/ui-skills/fixing-accessibility/

---
Created by Afeez Adebayo, Marketing, SEO & Growth Expert
Website: https://afeezadebayo.com
LinkedIn: https://www.linkedin.com/in/afeez-adebayo/
X: https://x.com/za_icon
Source: https://afeezadebayo.com/skills-md/frontend-development/accessibility/aria-live-announcements/

Licence: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). You may copy, share and adapt this skill, including for commercial use, as long as you credit Afeez Adebayo and link to the source URL above. Keep this notice in any copy or adapted version.
