Accessibility skill 4 of 12

ARIA Live Regions SKILL.md: Make Toasts and Status Messages Heard

The ARIA Live Announcements skill makes Claude, Cursor or another AI agent find toasts, form errors and status messages that a screen reader never announces. A static checker reads your components, then a browser probe clicks through the triggers and reports regions inserted late or hidden, so each fix is shown by DOM behaviour and not by reading code.

aria-live-announcements/SKILL.md, version 1.0.0, 3,046 words, plus scripts/live_check.py and scripts/live_probe.js.

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).

RequestUse instead
Moving focus to an error summary or a new headingkeyboard-focus.md
Names, roles and aria-hidden on controlsaria-names-roles.md
Native <dialog> and popover markupdialog-and-popover.md
Field labels, error text wording, validation timinghtml-forms.md
Real speech output, timing and verbosityOut 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.

InputAsk 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?"

Raw SKILL.md file

.md

This is the exact file agents read, including the credit header and licence notice. Save it as aria-live-announcements/SKILL.md.

---
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.

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.

MessageNeeds 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 moveYes
Error summary that receives focusNo. A change of focus is a change of context, so it is not a status message
Content revealed by a menu, accordion, tab or treeNo. That is SC 4.1.2 (aria-names-roles.md)
The results list itselfNo. 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.

CodeLevelMeaningFix
live-conditionalerrorRegion rendered behind &&, a ternary, v-if, *ngIf or @if: it arrives with its textStep 4
live-hiddenerrorRegion hidden with hidden, display:none, aria-hidden or v-showStep 4
live-injectedwarnRegion created by script with its text in one stepStep 4
live-conflictwarnrole=alert with aria-live=polite, or role=status with aria-live=assertiveStep 5
live-containerwarnLive attribute on main, form, nav, table, section, article, ul, ol or asideStep 4
live-region-countwarnMore than two regions in a fileStep 4
live-assertive-manywarnMore than one assertive or alert regionStep 5
toast-no-rolewarnElement named toast, snackbar, notification or similar with no live roleStep 4
live-redundant, live-weak-role, live-relevant, notify-usedinfoRepeated defaults, weakly supported roles or attributes, ariaNotify presentSteps 5 and 6
notify-no-fallbackwarnariaNotify() with no live region anywhereStep 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.

OutputMeaningAction
late-insertedA region was added to the DOM already holding textStep 4
hidden-changedText changed inside a region that was hiddenStep 4
changed=0 after a click that should announceThe click changed no live regionThe message goes elsewhere. Find the render path
regions-at-load=0No region exists before the first actionStep 4

Step 4: Fix the structure

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

RuleFix
The region must exist in the DOM, empty, before the text changesPut the empty element in the markup at load. Change its text, or add a child to it
Never toggle the region itselfReact, Vue and Angular fixes: references/patterns.md, section 3
Never hide the regiondisplay: 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 momentKeep it visible or clipped permanently
Keep regions smallA live attribute on a container re-reads every change inside it. Use a dedicated element
Use at most two regionsOne polite, one assertive, reused through one helper (references/patterns.md, section 2)
Toast without a roleGive a non-urgent toast role="status". A toast must not be the only channel for critical information (ibelick)

Step 5: Choose the urgency

MessageUseWhy
Saved, added, results count, progress, filter appliedrole="status" (polite)Spoken at the next graceful moment (MDN)
Error the user must act on now, session about to expirerole="alert" (assertive)Highest priority, may clear the speech queue (MDN)
Anything routineNever assertiveAn assertive update can cause a simultaneous polite one to be dropped (htmhell)
Time-limited contentAssertive, and a way to extend or turn it offMDN, WCAG timing
Updates that change faster than a person can listen (scores, tickers, per-keystroke results)off, or announce a summary after the user pausesMDN, 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

CaseFix
The same message must be announced twiceClear 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 DOMRemove it about 500 ms after it appears, to avoid stale re-announcements (htmhell)
Several updates in one burstCombine them into one message. ariaNotify() usually speaks only the latest call (MDN)
Browsers that support itdocument.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

MistakeHow to spot itDo instead
Region rendered only when there is a messagelive-conditional, late-insertedAlways rendered, text changes (step 4)
Region hidden with display: none or v-showlive-hidden, hidden-changedClipped visually-hidden class (step 4)
role="alert" for routine messageslive-assertive-manyrole="status" (step 5)
One live attribute on main, a form or a listlive-containerA small dedicated region (step 4)
Announcing every keystroke or result rowchanged far above the number of triggersOne 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.

ItemTokensStatusRead condition
A. Description (always loaded)110EstimateEvery session with the skill installed
B. SKILL.md body4,460EstimateWhen the skill fires
C. references/patterns.md904EstimateStep 4 for framework fixes, the announcer helper or the hidden-text CSS; step 6 for ariaNotify code
C. references/appendix.md729EstimateOnly for technique ids, support notes or the rival comparison
Script output (typical run)250EstimateChecker and probe output measured on the worked example
D. Typical run (A + B + script output)4,820EstimateCommon path, no references
D. Worst case (A + B + both references + 40 lines of output)7,600EstimateLarge input, every branch
E. Cheaper path4,670EstimateRun 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.

What this skill does

A live region only works if it is already in the page when its text changes. Frameworks break that easily: in React, {message && <div role="status"> builds the region and its text together, and Vue’s v-if and Angular’s @if do the same, according to Dennis Koppenhagen’s DEV Community article. The toast looks right and says nothing. WCAG 2.2 success criterion 4.1.3 (Level AA) asks for status messages to be programmatically determinable, and F103 is the listed failure when they are not.

The skill first sorts each message into one that needs announcing and one that does not: a message that takes focus is not a status message under the W3C’s wording. It then runs scripts/live_check.py over your source for conditional regions, hidden regions, script-injected alerts, container regions and assertive overuse. Then scripts/live_probe.js opens a page you own, clicks the triggers you list and reports regions added late and text changed while hidden. Fixes are proposed first and edited in batches you approve.

Who it is for

Use it if you are a frontend developer, a design-system maintainer or an accessibility tester, or if you review interfaces an AI tool generated. It fits when a saved, failed or added-to-cart message is silent for screen reader users, when a toast library never announces, or when an audit flags status messages. It does not cover focus management, accessible names, native dialogs or listening tests with a screen reader.

What is inside

The file is 3,046 words (version 1.0.0) with two scripts and two reference files. It holds:

  • Six guardrails, including editing only on a branch after approval, clicking only through pages you own and never where a click can pay, send or delete.
  • Seven inputs the agent must have first, with the exact question for each.
  • A workflow of seven steps, with tables that map each checker code to a fix and a politeness rule for every kind of message.
  • Two tools: scripts/live_check.py, a standard-library checker with thirteen codes, and scripts/live_probe.js, a Node and Playwright probe that prints counts and element descriptions but no page text.
  • A worked example with real output: 1 error and 2 warnings before on a three-button page, 0 after, plus a React cart that raised 3 errors and 5 warnings.
  • A report template, a starting prompt, five failure modes and an eight-item quality bar.
  • Two reference files, loaded only when a step points to them: an announcer helper with framework fixes and ariaNotify code, and an appendix with technique ids and support notes.
ItemTokensStatusRead when
Description110EstimateEvery session with the skill installed
SKILL.md body4,460EstimateWhen the skill fires
references/patterns.md904EstimateSteps 4 and 6, for framework fixes, the announcer helper or ariaNotify code
references/appendix.md729EstimateOnly for technique ids, support notes or the rival comparison
Typical run4,820EstimateDescription, body and script output
Worst case7,600EstimateBoth references and 40 lines of output
Cheaper path4,670EstimateRun the checker, fix flagged lines, skip the references

The figures come from the skill’s own count (words × 1.35 for prose, characters ÷ 3.5 for code and tables), not from a model tokeniser. The three rivals I read run about 175 to 2,430 tokens by the same method, and none ships a checker or a probe.

How to install and use it in Claude, Cursor and other agents

Claude. Keep the folder as aria-live-announcements/ with SKILL.md, scripts/ and references/ inside, and zip it. In Claude, open Customize, then Skills, then Upload a skill. In Claude Code, put the folder in ~/.claude/skills/ or .claude/skills/ in a project.

Cursor and other agents. Put the folder where your tool reads skills, or paste the body into its rules and keep the scripts beside your code. The probe needs Node and Playwright. Then send the starting prompt from the Templates section.

Before you install. Read the scripts first. The checker reads files and writes nothing. The probe opens a page and clicks the selectors you give it, so use it only on a page you own and never on a button that pays, sends or deletes. The skill tells the agent to edit only on a branch in batches you approve. Install skills only from sources you trust.

A worked example

An invented 10-line page has a Save button that writes into an empty role="status" element, a Remove button that writes into a status element hidden with display:none, and a Pay button that injects a new role="alert" paragraph with its text. The checker reports 1 error and 2 warnings. The probe clicks all three buttons and prints changed=1 late-inserted=1 hidden-changed=1: only the Save message follows the rules.

The skill labels the other two as unreliable on the strength of its sources, not on a speech test. One batch writes both messages into permanent, empty status and alert elements. The re-run prints 0 errors and 0 warnings and changed=2 late-inserted=0 hidden-changed=0. A person with a screen reader still has to listen to confirm.

Common mistakes

The skill is built around five ways this goes wrong:

  • Rendering the region only when there is a message. The region and its text arrive together. The skill keeps the region in the markup and changes only its text.
  • Hiding the region. display: none and visibility: hidden remove it from the accessibility tree, per HTMHell. The skill uses a clipped visually-hidden class.
  • Using role alert for routine messages. An assertive update can cause a polite one to be dropped. The skill reserves it for errors that need action now.
  • Putting a live attribute on a container. Every change inside it is read out. The skill uses a small dedicated region.
  • Announcing every keystroke. The skill announces one summary after the user pauses.

ARIA Names and Roles covers names, roles and aria-hidden on controls. Keyboard Focus covers moving focus, which is the alternative when an error summary takes focus. HTML Forms covers error wording and validation timing. Dialog and Popover covers native dialogs.

Frequently asked questions

It is an element whose text changes are announced by a screen reader without the user moving to it. You set it with aria-live or with a role such as status or alert. It must already exist in the page when its text changes, according to HTMHell. The skill checks that it does, and that it is not hidden.

Usually the live region is created at the same moment as its text. In React, Vue and Angular a conditional render such as an if or an and-expression does this. Other causes are display:none on the region and a message that arrives inside a script string. The skill flags all three and probes the page.

Use role=status for saved, added, results and progress messages, which are spoken at the next graceful moment. Use role=alert only for an error the user must act on now, because it can interrupt speech and cause a polite update to be dropped, according to MDN and HTMHell. The skill rejects routine alerts.

No. Role status already implies aria-live polite and aria-atomic true, and role alert implies assertive, according to HTMHell. Adding a conflicting value overrides the role default and confuses the reader of the code. The checker reports role and aria-live pairs that conflict and lists repeated defaults as information only.

Not yet on its own. MDN labels ariaNotify Baseline 2026, newly available since September 2026, so older browsers will not run it. It announces without a DOM change and usually speaks only the latest call. The skill keeps a live-region fallback and tells you to verify support before relying on it.

No. The checker reads code and the probe watches the DOM, but neither hears speech. Whether a message is spoken depends on the screen reader and browser pairing. The skill lists JAWS with Chrome, NVDA with Firefox, Narrator with Edge and VoiceOver with Safari for a person to test, and it makes no conformance claim.

The description is about 110 tokens and loads in every session. The SKILL.md body is about 4,460 tokens when the skill fires. A typical run is about 4,820 tokens with script output, the cheaper path is about 4,670, and the worst case with both references is about 7,600. All figures are estimates, not from a model tokeniser.

Changelog

  • 1.0.0, 30 October 2026: first release.

Use, adapt and share this skill under CC BY 4.0: credit Afeez Adebayo and link to this page.