Testland
Browse all skills & agents

screen-reader-test-author

Builds the full manual-accessibility artifact surface: step-by-step screen-reader test scripts for NVDA (Windows), JAWS (Windows), VoiceOver (macOS / iOS), or TalkBack (Android) with per-step keystroke + expected announcement; per-archetype WCAG 2.2 checklists (references/wcag-checklist.md); per-widget keystroke matrices pairing expected NVDA and VoiceOver announcements with the WCAG SC each row verifies (references/widget-matrix.md); and a guided NVDA / VoiceOver session protocol that merges script + checklist into a signed pass/fail session report. Use when authoring an accessibility-acceptance test, checklist, or widget matrix the team will run before sign-off, when scripting a manual a11y audit, OR when walking a tester through a guided screen-reader session.

Install with skills.sh (any agent)

npx skills add testland/qa --skill screen-reader-test-author
View source

screen-reader-test-author

Overview

Automated tools (axe-core, pa11y, Lighthouse) catch ~30-40% of accessibility issues - the structural ones. The remaining 60-70% require manual screen-reader testing by an actual human using NVDA / JAWS / VoiceOver / TalkBack. The team needs a repeatable script that any tester (not just the original author) can follow.

This skill takes a user flow and produces that script: per-step keystroke + expected announcement. Two sibling artifact shapes live in references/: a per-archetype WCAG 2.2 checklist generator for spec-review-time acceptance criteria (references/wcag-checklist.md) and per-widget keystroke matrices for hand-run NVDA / VoiceOver passes (references/widget-matrix.md). The "Running the session" section below turns script + checklist into a guided, signed session.

When to use

  • A high-stakes feature ships and needs accessibility sign-off.
  • Automated a11y tools are green but the team wants confidence the actual screen-reader experience is good.
  • Onboarding a new accessibility tester - they need a script to follow until they internalize the conventions.
  • Documenting an "a11y regression repro" when a bug ships.

Step 1 - Pick the screen reader

Most a11y audit conventions cover at least:

Screen readerPlatformBrowser pairing
NVDAWindowsFirefox or Chrome
JAWSWindowsChrome or Edge
VoiceOvermacOS / iOS / iPadOSSafari (macOS); Safari (iOS)
TalkBackAndroidChrome

For the most-coverage-per-effort, test NVDA + Firefox and VoiceOver + Safari - these are also the WAI-recommended pairs.

Start each script by naming the SR + browser + OS combination.

Step 2 - Define the flow

A flow is a sequence of user goals. Don't write step-by-step keystrokes upfront - that's Step 3. The flow is at user-intent level:

flow_name: "User edits their profile email"
preconditions:
  - User is logged in.
  - User is on the /profile page.
  - Screen reader is on; browser virtual cursor is at the page heading.

steps:
  - intent: "Navigate to the profile-edit form via main nav"
  - intent: "Tab through the form to the email field"
  - intent: "Edit the email value to a new valid email"
  - intent: "Submit the form"
  - intent: "Hear the success confirmation announcement"

Step 3 - Per intent, capture keystrokes + expected announcement

For each intent, the script row lists three things:

  • Keystroke - the actual key combo the tester presses.
  • Expected announcement - what the screen reader should say.
  • Why - the WCAG SC or APG pattern the announcement satisfies.

NVDA browse mode navigates with quick-keys (H by heading, F by form field); VoiceOver uses VO (Ctrl+Option) chords:

ActionVoiceOver shortcut
Read next / previousVO + Right / Left arrow
Next headingVO + Cmd + H
Next form controlVO + Cmd + J
Activate (Enter)VO + Space
Web rotorVO + U (form / heading / link list overlay)

A minimal NVDA + Firefox excerpt for the flow "User edits their profile email":

KeystrokeExpected announcement
H"Edit profile, heading level 2" - moves to next heading.
F"Email, edit, blank" - jumps to the first form field.
Tab, Enter"Save changes, button", then a live region announces "Profile saved".

Full per-reader scripts (NVDA + Firefox and VoiceOver + Safari) for this flow, with the WCAG / APG mapping in the "Why" column: references/screen-reader-scripts.md.

VoiceOver announcements are sometimes more verbose than NVDA's (role words like "edit text" where NVDA says "edit"); that is a vendor convention, not a bug to suppress.

Step 4 - Define what counts as PASS

For each step, the announcement should:

  1. Identify the element type - "button", "edit", "link", "heading level 2", etc.
  2. Read the visible label verbatim - if the visible label is "Save changes", the SR should say "Save changes" (not "Submit" or the id attribute).
  3. Convey state - for a checkbox, "checked" / "not checked"; for a button, "expanded" / "collapsed" / "pressed".
  4. Convey position in a set - for a tab, "1 of 4"; for a list item, "1 of 10".

Failures to flag:

FailureLikely cause
Announcement reads CSS class names or id attributesLabel is missing; SR fell back to other text.
Element type is wrong ("link" instead of "button")<a> used as a button; or role="link" on a button.
State is missingaria-expanded / aria-checked not set.
Position-in-set is missingaria-setsize / aria-posinset not set on list items.
Live region announcement doesn't fireRegion added with content already present, OR aria-live="off".

Step 5 - Wire to the test plan

The script becomes a manual test in the project's test plan. Common formats:

Markdown checklist (lightweight)

## A11y Acceptance - User edits profile email

### NVDA + Firefox (Windows)

- [ ] Step 1: Pressing H twice lands on "Profile information" heading.
- [ ] Step 2: Pressing F lands on the email field; announces "Email, edit, blank".
- [ ] Step 3: After submit, a live region announces "Profile saved".

### VoiceOver + Safari (macOS)

- [ ] Step 1: VO+Cmd+H twice lands on "Profile information" heading.
- [ ] Step 2: VO+Cmd+J lands on email; announces "Email, edit text".
- [ ] Step 3: After submit, live region announces "Profile saved".

Sign-off: ___________________ Date: __________

Test-management tool integration

For teams using TestRail / Xray / Zephyr - encode the flow as a manual test case with one step per row, expected result per row. The skill emits the matching format; per test-management-sync (when shipped) the test case can be uploaded automatically.

Running the session

When a component has passed automated scans and static review and a human tester needs a guided manual session to sign off acceptance, compose the script (Steps 1-5) with the component's per-archetype checklist (references/wcag-checklist.md) into one session package. Required inputs: the component's rendered URL, its archetype, and either an existing script or the intent-level flow (Step 2 format). Without a rendered URL the pre-conditions cannot be defined - stop and ask.

Build the session package

Merge script + checklist into a single session document:

  • Header: component name, archetype, URL, date, tester name (blank field), SR + browser pair.
  • NVDA section (Windows): single-letter quick-navigation keys. Per WebAIM NVDA guide (opens in new window): H navigates headings, F form controls, B buttons, K links; Shift+letter moves backwards; NVDA+Space toggles Browse and Focus modes. Per the NVDA user guide (opens in new window), the default NVDA modifier key is Insert (or numpadZero with numLock off).
  • VoiceOver section (macOS): per WebAIM VoiceOver guide (opens in new window), the VO keys are Control+Option; VO+Right/Left reads next/previous item; VO+Cmd+H navigates headings; VO+Cmd+J form controls; VO+Space activates; VO+U opens the Web Rotor.
  • Checklist rows mapped to each script step, so the tester sees the WCAG SC being verified alongside the keystroke.

Walk the tester through it

  1. Pre-conditions block: SR running, browser open to the URL, virtual cursor at page top (NVDA browse mode; VoiceOver with Web Rotor closed).
  2. For each intent step: the keystroke, the expected announcement, the WCAG SC being verified, and a [ ] PASS / [ ] FAIL / [ ] BLOCKED checkbox.
  3. After each overlay or composite step: a focus-return checkpoint per the W3C APG dialog-modal pattern (opens in new window) (focus must return to the trigger on close).
  4. After each live-region step: a timing checkpoint (announce within 1-2 s of state change; matches the aria-live="polite" contract per WCAG SC 4.1.3 Status Messages (opens in new window)).

Capture the session report

Write the results to a11y-sessions/<component>-<YYYY-MM-DD>-<sr>.md:

## Screen-Reader Session Report

**Component:** <name>  **Archetype:** <archetype>  **URL:** <url>
**Date:** <YYYY-MM-DD>  **Tester:** _______________
**SR + Browser:** NVDA + Firefox | VoiceOver + Safari

| Step | Intent | Keystroke | Expected announcement | WCAG SC | Result |
|------|--------|-----------|-----------------------|---------|--------|
| 1 | Navigate to component heading | H | "<Component>, heading level 2" | 1.3.1 / 2.4.6 | [ ] PASS [ ] FAIL [ ] BLOCKED |

### Summary

| Verdict | Count |
|---------|-------|
| PASS / FAIL / BLOCKED | N |

**Overall:** PASS / FAIL / INCOMPLETE

**Failures to remediate:**
1. <Step N>: <what was announced> vs. <expected> - likely cause + WCAG SC.

**Sign-off:** _______________ Date: __________

The session never modifies component source. iOS VoiceOver and TalkBack (Android) have different gesture models - author a separate mobile script (Step 1) rather than reusing the desktop session.

Anti-patterns

Anti-patternWhy it failsFix
Testing on only one screen readerNVDA bugs ≠ JAWS bugs ≠ VoiceOver bugs.At least two reader/browser pairs (NVDA+Firefox, VoiceOver+Safari).
Asking the developer to test their own workFamiliarity bias; the dev knows how it should sound.Independent tester or accessibility specialist; use the script blindly.
Recording expected announcements verbatim from one versionScreen-reader announcement strings change between versions.Test the announcement contains key elements (label, role, state); avoid string-equality.
Skipping the "Why" columnTester can't generalize from "this announcement was wrong" to "the underlying ARIA pattern is broken."Always link to the WCAG SC or APG pattern.

Limitations

  • Manual. Cannot fully automate; the tester is the QA instrument.
  • Vendor variance. A test script that passes on NVDA might fail on JAWS due to vendor-specific announcement choices; the underlying code may be correct.
  • Mobile tests. TalkBack and iOS VoiceOver have very different gesture sets from desktop SRs; mobile flows need separate scripts.

References

  • W3C WCAG 2.2 - https://www.w3.org/TR/WCAG22/
  • WebAIM Screen Reader Survey - https://webaim.org/projects/screenreadersurvey/
  • NVDA documentation - https://www.nvaccess.org/files/nvda/documentation/userGuide.html
  • VoiceOver Getting Started - https://www.apple.com/accessibility/mac/vision/ + Apple developer docs
  • references/wcag-checklist.md - per-archetype WCAG 2.2 checklist builder (spec-review acceptance criteria).
  • references/widget-matrix.md - per-widget keystroke matrices with expected NVDA / VoiceOver announcements per WCAG SC.
  • wcag-keyboard-navigation, aria-authoring-patterns - patterns this skill references for the "Why" column.

Per-screen-reader worked scripts

View source (opens in new window)

Per-screen-reader worked scripts

Full NVDA + Firefox and VoiceOver + Safari scripts for the sample flow "User edits their profile email." SKILL.md Step 3 has the row template, the VoiceOver chord table, and a minimal excerpt; these are the complete per-reader versions, including the WCAG SC / APG notes that populate the "Why" column.

NVDA + Firefox (Windows)

## NVDA + Firefox (Windows) - User edits their profile email

### Pre-conditions
- NVDA is running.
- Firefox is on https://app.example.com/profile.
- Virtual cursor mode is on (NVDA default for browsers).

### Step 1 - Navigate to the profile-edit form

| Keystroke | Expected announcement                                          |
|-----------|----------------------------------------------------------------|
| H         | "Edit profile, heading level 2" - moves to next heading.       |
| H         | "Profile information, heading level 3" - moves further.        |
| F         | "Email, edit, blank" - jumps to first form field.              |

NVDA's `H` quick-key navigates by heading; `F` by form field.
The announcements should match the visible heading text and field
labels (per WCAG SC 2.4.6 Headings and Labels).

### Step 2 - Edit the email value

| Keystroke         | Expected announcement                                  |
|-------------------|--------------------------------------------------------|
| Enter (focus mode) | "Email, edit, has autocomplete" - enters focus mode.  |
| (type new email)  | (each character spoken if `say characters` is on)     |

### Step 3 - Submit the form

| Keystroke | Expected announcement                                          |
|-----------|----------------------------------------------------------------|
| Tab       | "Save changes, button" - moves to submit.                       |
| Enter     | (no announcement immediately; wait for live region)             |

### Step 4 - Success confirmation

| Behavior          | Expected announcement                                          |
|-------------------|----------------------------------------------------------------|
| (page response)   | "Profile saved" - announced via `aria-live="polite"` region.   |

If no announcement: the success region is missing `aria-live`
attribute, OR the region is added to the DOM with content already
in it (live regions only announce **changes** post-mount).

VoiceOver + Safari (macOS)

VoiceOver uses VO+arrow keys (Ctrl+Option+arrow) and different quick-key patterns from NVDA. Re-author the same flow:

## VoiceOver + Safari (macOS) - User edits their profile email

### Step 1 - Navigate to the profile-edit form

| Keystroke    | Expected announcement                                       |
|--------------|-------------------------------------------------------------|
| VO + Cmd + H | "Edit profile, heading level 2"                              |
| VO + Cmd + J | "Email, edit text"                                           |

VoiceOver's announcements are sometimes more verbose than NVDA's (reads role words like "edit text" where NVDA says "edit"). This is a vendor convention; don't try to suppress it.

Per-archetype WCAG 2.2 checklist builder

View source (opens in new window)

Per-archetype WCAG 2.2 checklist builder

Companion reference for screen-reader-test-author. Consult when a component spec needs explicit a11y acceptance criteria before implementation, when building an a11y-acceptance gate at design-review time, or for pre-PR self-review. The checklist pairs with the SKILL.md test narratives for manual verification and with a11y-violation-gate for automated scans.

Step 1 - Identify the component archetype

Each archetype has its own checklist pattern:

ArchetypeExamples
Static text / displayHeading, badge, banner, blockquote.
Interactive - single triggerButton, link, icon button.
Interactive - form inputText input, checkbox, radio, select, switch.
Interactive - multi-stateDisclosure, accordion, tabs, popover.
Interactive - overlayModal, drawer, dropdown menu, command palette.
CompositeCombobox, date picker, multi-select, tree.
Live regionToast, alert, in-page status, error banner.
Layout / navigationHeader, footer, breadcrumb, pagination.

Step 2 - Apply the per-archetype checklist

Static text / display

  • [ ] Heading levels are sequential (h1 -> h2 -> h3; no skips).
  • [ ] Color contrast >= 4.5:1 (or 3:1 for large text per wcag-color-contrast).
  • [ ] Decorative-only images have empty alt=""; meaningful images have descriptive alt.
  • [ ] Information conveyed by color alone is also conveyed another way (text, icon, pattern; SC 1.4.1).

Interactive - single trigger

  • [ ] Native <button> / <a href> used (no <div onclick>).
  • [ ] Visible focus indicator at >= 3:1 contrast vs. background.
  • [ ] Activatable via Enter (and Space for <button>).
  • [ ] Accessible name matches visible label (or is announced via aria-label / aria-labelledby if no visible label).
  • [ ] On press: announces state change if applicable (e.g. toggle button announces aria-pressed).
  • [ ] Loading / disabled states programmatically conveyed (aria-disabled / aria-busy).

Interactive - form input

  • [ ] Has a <label for="..."> OR aria-labelledby referencing a visible label.
  • [ ] Required fields marked via required (native) AND a non-color cue.
  • [ ] Validation errors conveyed via aria-invalid="true" plus an error message linked via aria-describedby.
  • [ ] On invalid submit: focus moves to the first invalid field.
  • [ ] Autocomplete attributes set per WCAG 2.2 SC 1.3.5 (autocomplete="email", "name", etc.).
  • [ ] Placeholder is NOT used as the only label.

Interactive - multi-state (disclosure / accordion / tabs)

  • [ ] aria-expanded reflects open/closed state on the trigger.
  • [ ] aria-controls references the controlled region's id.
  • [ ] Keyboard interaction matches the APG pattern (opens in new window):
    • Disclosure: Enter/Space toggles.
    • Accordion: Tab between headers; Enter/Space toggles.
    • Tabs: Tab to active tab; Left/Right to navigate; Home/End.
  • [ ] Hidden content uses the hidden attribute or display: none (NOT visibility: hidden or opacity: 0, which leave the content focusable).

Interactive - overlay (modal / drawer / popover)

(See wcag-keyboard-navigation references/focus-trap.md for the 6-step pattern.)

  • [ ] role="dialog" (modal) OR role="alertdialog" (interrupt).
  • [ ] aria-modal="true" for modals.
  • [ ] aria-labelledby references the dialog's title.
  • [ ] On open: focus moves into the dialog.
  • [ ] Tab cycles within the dialog; doesn't escape to the page.
  • [ ] Escape closes (for non-destructive dialogs).
  • [ ] On close: focus returns to the triggering element.
  • [ ] Outside content is inert (or pre-inert focusable cycle).

Composite (combobox, date picker, multi-select)

(See aria-authoring-patterns.)

  • [ ] Match the matching APG pattern's full keyboard model.
  • [ ] aria-expanded on the trigger.
  • [ ] aria-controls linking trigger to the popup.
  • [ ] aria-activedescendant (when not moving DOM focus) OR moves DOM focus into the popup.
  • [ ] Selected state announced via aria-selected (listbox) or aria-checked (tree).
  • [ ] Type-ahead works (typing a letter focuses matching item).

Live region

  • [ ] Container has role="status" (polite) or role="alert" (assertive) OR aria-live="polite" / "assertive".
  • [ ] Container exists in the DOM before content is inserted (live regions only announce changes; pre-mounted with content suppresses the first announcement).
  • [ ] Content updates are detected - for SPA frameworks, ensure the render produces a real DOM mutation rather than a virtual-DOM-only update.

Layout / navigation

  • [ ] Landmarks declared: <header> / <main> / <nav> / <footer> / <aside>. (One <main> per page.)
  • [ ] Skip-to-main-content link as the first focusable element.
  • [ ] Pagination controls have aria-label (e.g. "Pagination").
  • [ ] Breadcrumb has aria-label="Breadcrumb" and aria-current="page" on the current page.

Step 3 - Add per-component customization

Beyond the archetype defaults, the spec may declare:

  • Custom keyboard shortcuts - verify SC 2.1.4 (Character Key Shortcuts).
  • Animation / motion - verify SC 2.3.3 and respect prefers-reduced-motion.
  • Auto-rotating content (carousel, ticker) - pause / play control; SC 2.2.2 (Pause, Stop, Hide).
  • Time-limited interactions (countdown timer) - pause / extend control; SC 2.2.1 (Timing Adjustable).

Step 4 - Emit the artifact

Manual-verification items must be emitted as concrete keystrokes and expected announcements - never as a bare skill name. The artifact ships into the user's repo and has to be runnable by a tester holding only that file. Baseline commands (per the NVDA user guide (opens in new window) and Apple VoiceOver (opens in new window); same set as SKILL.md Step 3):

ActionNVDA + Firefox (Windows)VoiceOver + Safari (macOS)
Next headingHVO (Ctrl+Option) + Cmd + H
Next form fieldFVO + Cmd + J
Read next / previous itemDown / Up arrowVO + Right / Left arrow
Enter focus mode on a fieldEnter(automatic)
Activate the focused controlEnterVO + Space
Element list overlay-VO + U (web rotor)

A step passes when the announcement identifies the element type, reads the visible label verbatim, and conveys state and position-in-set where applicable.

Markdown checklist (for spec / PR review)

## A11y Checklist - `<ComponentName>`

**Archetype:** Interactive - overlay (modal)

### Required (must pass before merge)

- [ ] Modal has `role="dialog"` and `aria-modal="true"`.
- [ ] Modal title is `aria-labelledby`-referenced.
- [ ] On open: focus moves to first focusable inside modal.
- [ ] Tab cycles within modal; outside content is `inert`.
- [ ] Escape closes; focus returns to trigger.
- [ ] Close button has accessible name "Close" or "Dismiss".
- [ ] Focus indicator on close button at >= 3:1 contrast.

### Per-component (this design adds)

- [ ] Confirmation modal's "Delete" button has `aria-describedby`
      linking to the warning text.
- [ ] On confirm: live region announces "Deleted" via
      `aria-live="polite"`.

### Verification

- Automated: axe-core scan with `dialog` rule enabled.
- Manual (NVDA + Firefox, Windows): press `Enter` on the trigger -
  NVDA announces "Confirm delete, dialog"; `Tab` cycles inside the
  dialog and never reaches page content; `Escape` closes and focus
  returns to the trigger, announced as "Delete, button".
- Manual (VoiceOver + Safari, macOS): `VO`+`Space` on the trigger,
  `VO`+`Right arrow` to walk the dialog contents, `Escape` to
  close - same expected announcements.
- Code review: against the checklist above.

YAML test plan (for test-management tools)

component: ConfirmModal
archetype: overlay-modal
checks:
  - id: AC-MODAL-1
    description: Modal has role and aria-modal
    severity: blocker
    method: automated
    rule: axe::role-modal
  - id: AC-MODAL-2
    description: Focus moves to first focusable on open
    severity: blocker
    method: e2e-test
    framework: playwright
  - id: AC-MODAL-3
    description: Escape closes; focus restored
    severity: blocker
    method: e2e-test
  - id: AC-MODAL-4
    description: Live region announces "Deleted" on confirm
    severity: blocker
    method: manual
    tester_role: a11y-specialist
    instructions: >-
      NVDA + Firefox (Windows): Tab to "Delete", press Enter.
      NVDA must speak "Deleted" from the aria-live="polite" region
      without focus moving. VoiceOver + Safari (macOS): VO+Space on
      "Delete"; same announcement expected.

Anti-patterns

Anti-patternWhy it failsFix
Generic checklist for all componentsStatic text doesn't need keyboard checks; modal does.Per-archetype checklist; pick once.
Checklist created post-implementationA11y becomes a retrofit; bugs found late.Build the checklist from the spec, before implementation.
Marking items "won't fix" without rationaleLoss of institutional knowledge; same item resurfaces.Document each waiver with a date + reviewer initials.
One mega-checklist per component (50+ items)Reviewer fatigue; rubber-stamping.Per-archetype defaults; only add custom items the design demands.

References

  • W3C WCAG 2.2 - https://www.w3.org/TR/WCAG22/
  • W3C ARIA Authoring Practices - apg (opens in new window).
  • Related: wcag-keyboard-navigation, wcag-color-contrast, aria-authoring-patterns, a11y-violation-gate.

Per-widget manual accessibility test matrices

View source (opens in new window)

Per-widget manual accessibility test matrices

Companion reference for screen-reader-test-author. Consult when a rendered widget has cleared automated scanning and a tester needs a fill-in pass/fail sheet to run by hand against NVDA and VoiceOver. One row = one keystroke + the expected focus behavior + the expected NVDA announcement + the expected VoiceOver announcement + the WCAG 2.2 success criterion that row verifies - so a failure is attributable the moment it is observed.

Where the SKILL.md narrative is organized by user flow ("user edits their profile email"), these matrices are organized by widget archetype, so the same block is reused on every screen that contains that widget. Run automated scanning first to clear structural defects, then run the matrix - rule engines cannot observe focus order in practice, hear an announcement, or confirm focus returned to the trigger.

Announcement strings are expectations, not guarantees

Screen readers report a control's accessible name, role, and state, but the exact wording is chosen by the reader and varies by version and paired browser (NVDA reports name, type, value, state, description, and position for the focused control per the NVDA User Guide (opens in new window) and WebAIM: Using NVDA (opens in new window)).

Treat every string in the matrices below as a containment assertion: the announcement must contain the accessible name, the role word, and any applicable state word. Do not assert string equality. Record the actual string in the Notes column, and record the NVDA version, VoiceOver / macOS version, and browser at the top of the sheet.

Test stacks

Run two stacks, because a defect that one stack papers over the other exposes:

StackModifier / entry convention
NVDA on Windows, paired with FirefoxThe NVDA modifier is numpadZero (with NumLock off) or insert, remappable to Caps Lock. Browse mode uses single-letter navigation: h heading, f form field, k link, d landmark, l list, t table; add Shift to move backwards (NVDA User Guide (opens in new window)).
VoiceOver on macOS, paired with SafariThe VO modifier is Control-Option pressed together, or Caps Lock (Apple: Use the VoiceOver rotor on Mac (opens in new window)); VO-Space bar performs an item's default action, such as clicking a button (Apple: VoiceOver keyboard commands (opens in new window)).

VoiceOver does not infer a label from proximity: an explicit <label for> or aria-label is required for the field name to be announced (WebAIM: Using VoiceOver to Evaluate Web Accessibility (opens in new window)).

How to build a sheet

  1. Inventory the widget archetypes on screen. A screen is almost always a composition: a form with three text inputs, one checkbox, one combobox, and a submit button that opens a confirmation dialog uses five blocks.
  2. Copy the matching blocks into one sheet, in the order the widgets appear in the DOM. Do not paraphrase the expected column; the value of the matrix is that two testers on different machines assert the same thing.
  3. Substitute the placeholders. Replace {name} with the widget's visible label text, {option} with a real option string, {error} with the real validation message. If a widget has no visible label, that is already a finding: record it against SC 4.1.2 before running the block.
  4. Run each row and fill Result (PASS, FAIL, or N/A), pasting the actual announcement into Notes whenever it differs from the expectation.
  5. Attribute every failure to its row's success criterion. A failing row reports as "row 4, Escape did not close the dialog, SC 2.1.2 Level A" rather than "the dialog is not accessible".

Universal traversal (prepend to every sheet)

Sequential navigation with Tab and Shift+Tab applies to every focusable element regardless of archetype (WebAIM: Keyboard Accessibility (opens in new window)).

#KeyExpected focus / behaviorExpected NVDAExpected VoiceOverWCAG 2.2 SCResult
U1Tab repeatedly, first to lastEvery interactive element receives focus exactly once, in an order matching the visual and reading orderEach stop announces name and roleEach stop announces name and role2.4.3 Focus Order (A); 2.1.1 Keyboard (A)
U2Tab (observe, do not act)A visible focus indicator is present at every stopn/a (visual check)n/a (visual check)2.4.7 Focus Visible (AA)
U3Shift+Tab back through the same pathFocus retraces the U1 order in reverseSame names and roles in reverseSame names and roles in reverse2.4.3 Focus Order (A)
U4Tab continuously past the widgetFocus leaves the widget and continues into the rest of the pageAnnounces the next page elementAnnounces the next page element2.1.2 No Keyboard Trap (A)
U5NVDA f / h / d in browse mode; VoiceOver VO-U rotorStructure navigation reaches the widget by form field, heading, or landmarkJumps to the next form field, heading, or landmarkRotor lists the item under its category4.1.2 Name, Role, Value (A)

Row U4 is the operative check for SC 2.1.2: focus that can enter a component with the keyboard must be able to leave it with the keyboard (WCAG 2.2 (opens in new window)). Inside a modal dialog the equivalent exit is the Escape row, not Tab; see the dialog block.

Row U5's structure keys: NVDA single-letter navigation (f, h, d) (NVDA User Guide (opens in new window)); the VoiceOver rotor opens with VO-U (or VO-Command-Left/Right Arrow), and items within a category are reached with the Up and Down Arrow keys (Apple: Use the VoiceOver rotor on Mac (opens in new window)).

Button

Per the APG Button pattern (opens in new window): Space and Enter both activate; the element has role button and an accessible label, computed by default from its text content.

#KeyExpected focus / behaviorExpected NVDAExpected VoiceOverWCAG 2.2 SCResult
B1TabButton receives focus; visible indicator"{name}, button""{name}, button"4.1.2 (A); 2.4.7 (AA)
B2EnterThe button's action firesResult of the action announced or observedResult of the action announced or observed2.1.1 Keyboard (A)
B3SpaceThe same action fires as for EnterSame as B2Same as B22.1.1 Keyboard (A)
B4VO-Space bar (VoiceOver only)The button's default action firesn/aSame as B22.1.1 Keyboard (A)
B5Tab onto a button whose action is unavailableButton is still reachable when marked aria-disabled="true""{name}, button, unavailable""{name}, dimmed, button"4.1.2 (A)

Row B5 covers the APG convention that an unavailable button carries aria-disabled="true" rather than the native disabled attribute, which would remove it from the tab sequence and make B5 N/A rather than a failure (APG Button pattern (opens in new window)).

Toggle button

A toggle button carries an aria-pressed state (true on, false off), and its label must stay constant across toggles (APG Button pattern (opens in new window)).

#KeyExpected focus / behaviorExpected NVDAExpected VoiceOverWCAG 2.2 SCResult
T1Tab (state off)Button receives focus"{name}, toggle button, not pressed""{name}, toggle button, off"4.1.2 (A); 2.4.7 (AA)
T2SpaceState flips to on; the visible label does not change"pressed" announced on the state change"on" announced on the state change4.1.2 (A); 2.1.1 (A)
T3Space againState flips back to off"not pressed""off"4.1.2 (A)
T4Shift+Tab then Tab backRe-entering the button re-announces the current state"{name}, toggle button, pressed""{name}, toggle button, on"4.1.2 (A)

If the label text changes between T2 and T3 (for example "Mute" becoming "Unmute" while aria-pressed also flips), record it: the state is being conveyed twice and contradictorily, against the APG note above.

Checkbox

Space toggles a focused checkbox; the element has role checkbox with aria-checked set to true, false, or mixed (APG Checkbox pattern (opens in new window)).

#KeyExpected focus / behaviorExpected NVDAExpected VoiceOverWCAG 2.2 SCResult
C1Tab (unchecked)Checkbox receives focus"{name}, check box, not checked""{name}, checkbox, unchecked"4.1.2 (A); 2.4.7 (AA)
C2SpaceState changes to checked"checked""checked"2.1.1 (A); 4.1.2 (A)
C3Space againState changes back to unchecked"not checked""unchecked"4.1.2 (A)
C4Tab onto a tri-state checkbox in the mixed stateFocus lands on the control"half checked" or "partially checked""mixed"4.1.2 (A)
C5EnterNo state change expected; Space is the state key per APGNo state announcementNo state announcement4.1.2 (A)

Row C4 wording differs most across versions of both stacks; assert only that a third state distinct from checked and unchecked is spoken.

Text input / form field

Tab enters and exits a text field and arrow keys move within the text (WebAIM: Keyboard Accessibility (opens in new window)). NVDA switches into focus mode automatically on interactive fields when automatic focus mode is enabled (NVDA User Guide (opens in new window)).

#KeyExpected focus / behaviorExpected NVDAExpected VoiceOverWCAG 2.2 SCResult
F1TabField receives focus; visible indicator"{name}, edit" plus current value or "blank""{name}, text field" plus current value4.1.2 (A); 2.4.7 (AA)
F2Tab onto a required fieldFocus lands; required state exposed"{name}, edit, required""{name}, required, text field"4.1.2 (A); 3.3.2 Labels or Instructions (A)
F3Type charactersText is entered; the caret advancesCharacters echoed if character echo is onCharacters echoed if key echo is on2.1.1 (A)
F4Left Arrow / Right ArrowCaret moves within the value, focus stays in the fieldCharacter at the caret spokenCharacter at the caret spoken2.1.1 (A)
F5TabFocus moves to the next control in DOM orderNext control's name and roleNext control's name and role2.4.3 (A)
F6Shift+TabFocus returns to the previous controlPrevious control's name and rolePrevious control's name and role2.4.3 (A)
F7Tab onto a field carrying a validation errorFocus lands; the error text is associated, typically via aria-describedby"{name}, edit, invalid entry, {error}""{name}, text field, {error}"3.3.1 Error Identification (A); 4.1.2 (A)

SC 3.3.1 requires that a detected input error is identified and described to the user in text (WCAG 2.2 (opens in new window)). Row F7 fails if the error is only a red border or an icon, even when the field is otherwise announced correctly.

Modal dialog

APG specifies the container has role="dialog" with aria-modal="true" and is labeled by aria-labelledby or aria-label (APG Dialog (Modal) pattern (opens in new window)).

#KeyExpected focus / behaviorExpected NVDAExpected VoiceOverWCAG 2.2 SCResult
D1Enter or Space on the trigger"When a dialog opens, focus moves to an element inside the dialog" (APG (opens in new window))"{dialog name}, dialog" then the focused element"{dialog name}, web dialog" then the focused element2.1.1 (A); 4.1.2 (A)
D2Tab from the last tabbable element"Moves focus to the next tabbable element inside the dialog. If focus is on the last tabbable element inside the dialog, moves focus to the first tabbable element inside the dialog" (APG (opens in new window))First element's name and roleFirst element's name and role2.4.3 (A)
D3Shift+Tab from the first tabbable element"Moves focus to the previous tabbable element inside the dialog. If focus is on the first tabbable element inside the dialog, moves focus to the last tabbable element inside the dialog" (APG (opens in new window))Last element's name and roleLast element's name and role2.4.3 (A)
D4Tab cycling repeatedly"Tab and Shift + Tab do not move focus outside the dialog" (APG (opens in new window)); nothing behind the overlay is reachedOnly dialog content announcedOnly dialog content announced2.4.3 (A)
D5Escape"Closes the dialog" (APG (opens in new window)); this is the keyboard exit that satisfies SC 2.1.2 given D4The trigger's name and role re-announcedThe trigger's name and role re-announced2.1.2 (A)
D6Enter or Space on the close buttonDialog closes; "focus returns to the element that invoked the dialog" (APG (opens in new window))"{trigger name}, button""{trigger name}, button"2.4.3 (A)

Read D4 and D5 together. An intentional focus cycle is a design requirement of the pattern, not a violation, only because D5 provides a keyboard exit. If D5 fails while D4 passes, the widget is a keyboard trap under SC 2.1.2, not a correctly implemented dialog.

Menu button

APG specifies the trigger has role="button", aria-haspopup "set to either menu or true", and aria-expanded set to true when the menu is displayed and false when it is hidden (APG Menu Button pattern (opens in new window)). Behavior once the menu is open is defined by the menu pattern (APG Menu and Menubar pattern (opens in new window)).

#KeyExpected focus / behaviorExpected NVDAExpected VoiceOverWCAG 2.2 SCResult
M1TabTrigger receives focus, collapsed"{name}, button, collapsed" or "has pop up menu""{name}, pop up button, collapsed"4.1.2 (A); 2.4.7 (AA)
M2Enter"opens the menu and places focus on the first menu item" (APG (opens in new window))"{first item}, menu item, 1 of {n}""{first item}, menu item"2.1.1 (A); 4.1.2 (A)
M3Space"Opens the menu and places focus on the first menu item" (APG (opens in new window))Same as M2Same as M22.1.1 (A)
M4Down Arrow on the trigger"(Optional) opens the menu and moves focus to the first menu item" (APG (opens in new window)); mark N/A if unimplementedSame as M2Same as M22.1.1 (A)
M5Up Arrow on the trigger"(Optional) opens the menu and moves focus to the last menu item" (APG (opens in new window)); mark N/A if unimplemented"{last item}, menu item, {n} of {n}""{last item}, menu item"2.1.1 (A)
M6Down Arrow inside the menu"When focus is in a menu, moves focus to the next item, optionally wrapping from the last to the first" (APG (opens in new window))"{item}, menu item, {i} of {n}""{item}, menu item"2.4.3 (A); 4.1.2 (A)
M7Up Arrow inside the menu"When focus is in a menu, moves focus to the previous item, optionally wrapping from the first to the last" (APG (opens in new window))Previous item announcedPrevious item announced2.4.3 (A)
M8Home / End inside the menu"If arrow key wrapping is not supported, moves focus to the first item" / "to the last item in the current menu or menubar" (APG (opens in new window))First / last item announcedFirst / last item announced2.1.1 (A)
M9Escape inside the menu"Close the menu that contains focus and return focus to the element or context, e.g., menu button or parent menuitem, from which the menu was opened" (APG (opens in new window))"{name}, button, collapsed""{name}, pop up button, collapsed"2.1.2 (A); 2.4.3 (A)
M10Enter on a menu item"Activates the item and closes the menu" (APG (opens in new window))Action result; then the trigger re-announcedAction result; then the trigger re-announced2.1.1 (A)

Combobox

APG specifies role="combobox" on the input, aria-expanded set to false when the popup is hidden and true when visible, aria-controls referencing the popup, and aria-activedescendant referencing the focused popup element (APG Combobox pattern (opens in new window)).

#KeyExpected focus / behaviorExpected NVDAExpected VoiceOverWCAG 2.2 SCResult
K1Tab"The combobox is in the page Tab sequence" (APG (opens in new window)); popup hidden"{name}, combo box, collapsed""{name}, combo box" plus current value4.1.2 (A); 2.4.7 (AA)
K2Down Arrow on the combobox"If the popup is available, moves focus into the popup" (APG (opens in new window))"expanded" then "{option}, 1 of {n}""expanded" then "{option}"2.1.1 (A); 4.1.2 (A)
K3Alt+Down Arrow on the combobox"If the popup is available but not displayed, displays the popup without moving focus" (APG (opens in new window))"expanded"; focus stays on the input"expanded"; focus stays on the input4.1.2 (A)
K4Up Arrow on the combobox"If the popup is available, places focus on the last focusable element in the popup" (APG (opens in new window))"{last option}, {n} of {n}""{last option}"2.1.1 (A)
K5Down Arrow in the popup"Moves focus to and selects the next option" (APG (opens in new window))"{option}, {i} of {n}, selected""{option}, selected"2.4.3 (A); 4.1.2 (A)
K6Up Arrow in the popup"Moves focus to and selects the previous option" (APG (opens in new window))Previous option plus position and selection statePrevious option plus selection state2.4.3 (A)
K7Enter in the popup"Accepts the focused option in the listbox by closing the popup, placing the accepted value in the combobox" (APG (opens in new window))"{option}" then "collapsed""{option}" then the input's new value2.1.1 (A); 4.1.2 (A)
K8Escape in the popup"Closes the popup and returns focus to the combobox" (APG (opens in new window))"{name}, combo box, collapsed""{name}, combo box"2.1.2 (A); 2.4.3 (A)
K9Tab with the popup openFocus leaves the combobox entirely and reaches the next page controlNext control's name and roleNext control's name and role2.1.2 (A)

Success criterion lookup

Every SC the matrices cite, with its normative anchor. All are current in WCAG 2.2 (WCAG 2.2 (opens in new window)); note WCAG 2.2 removed 4.1.1 Parsing.

SCTitleLevelWhat a matrix row proves
2.1.1KeyboardA"All functionality of the content is operable through a keyboard interface without requiring specific timings for individual keystrokes, except where the underlying function requires input that depends on the path of the user's movement and not just the endpoints."
2.1.2No Keyboard TrapA"If keyboard focus can be moved to a component of the page using a keyboard interface, then focus can be moved away from that component using only a keyboard interface, and, if it requires more than unmodified arrow or tab keys or other standard exit methods, the user is advised of the method for moving focus away."
2.4.3Focus OrderA"If a web page can be navigated sequentially and the navigation sequences affect meaning or operation, focusable components receive focus in an order that preserves meaning and operability."
2.4.7Focus VisibleAA"Any keyboard operable user interface has a mode of operation where the keyboard focus indicator is visible."
3.3.1Error IdentificationAA detected input error is identified and described to the user in text.
3.3.2Labels or InstructionsALabels or instructions are provided when content requires user input.
4.1.2Name, Role, ValueAFor all UI components, the name and role can be programmatically determined; states and values can be programmatically set and are notified to assistive technologies.

Worked example

A ConfirmDeleteDialog opened by a "Delete project" button, containing a "Type the project name to confirm" text input and Cancel / Delete buttons. The sheet composes: Universal traversal, Button (trigger), Modal dialog, Text input, Button (Cancel), Button (Delete).

# Widget accessibility matrix: ConfirmDeleteDialog

Stack A: NVDA 2025.1 + Firefox 141 on Windows 11
Stack B: VoiceOver on macOS 15.4 + Safari 18.4
Tester: ____________________   Date: 2026-07-19

| # | Key | Expected focus / behavior | Expected NVDA | Expected VoiceOver | WCAG 2.2 SC | Result | Notes |
|---|---|---|---|---|---|---|---|
| U1 | Tab first to last | 4 stops, DOM order = visual order | name + role each stop | name + role each stop | 2.4.3 (A) | | |
| U2 | Tab (observe) | focus ring visible at all 4 stops | n/a | n/a | 2.4.7 (AA) | | |
| B1 | Tab | "Delete project" button focused | "Delete project, button" | "Delete project, button" | 4.1.2 (A) | | |
| D1 | Enter on trigger | focus moves inside the dialog | "Confirm delete, dialog" | "Confirm delete, web dialog" | 2.1.1 (A) | | |
| D2 | Tab from Delete (last) | wraps to the text input (first) | "Project name, edit, blank" | "Project name, text field" | 2.4.3 (A) | | |
| D4 | Tab x6 | page behind is never reached | dialog content only | dialog content only | 2.4.3 (A) | | |
| F7 | Tab to input after bad value | error text associated | "Project name, edit, invalid entry, Names do not match" | "Project name, text field, Names do not match" | 3.3.1 (A) | | |
| D5 | Escape | dialog closes | "Delete project, button" | "Delete project, button" | 2.1.2 (A) | | |
| D6 | Enter on Cancel | dialog closes, focus returns to trigger | "Delete project, button" | "Delete project, button" | 2.4.3 (A) | | |

A completed run reports as a per-row verdict list, not a prose summary:

ConfirmDeleteDialog: 7 PASS, 2 FAIL, 0 N/A
FAIL D5  Escape (NVDA + Firefox)   SC 2.1.2 A   dialog stayed open; no keyboard exit
FAIL F7  Tab to errored input (both stacks)  SC 3.3.1 A   heard "Project name, edit"; error text not announced

Anti-patterns

Anti-patternWhy it fails
Asserting the announcement string with ==Wording is chosen by the screen reader and drifts across versions and paired browsers. Assert that name, role, and state are each contained in the announcement.
Running one stack onlyNVDA and VoiceOver expose different bugs on the same markup, and each is paired with a different browser engine.
Recording only "Tab reaches it"Reachability satisfies part of SC 2.1.1 and nothing else. The order (2.4.3), the indicator (2.4.7), and the announced role and state (4.1.2) are separate rows for a reason.
Marking the dialog block PASS because focus cyclesThe cycle is only correct in combination with a working Escape row. Without it the same behavior is a SC 2.1.2 keyboard trap.
Filling Result from the code rather than the running widgetThe matrix asserts observed runtime behavior. Static review of the source does not substitute for it.
Substituting an automated scan for the sheetRule engines detect structural defects only; they cannot hear an announcement, judge focus order, or confirm focus returned to the trigger.

Limitations

  • Version drift. Every announcement column is a starting expectation. Re-derive against the exact NVDA build, VoiceOver / macOS version, and browser under test, and record those versions on the sheet.
  • Browser pairing changes results. The same markup can announce differently under NVDA with Chrome than with Firefox. Pin the pairing per stack and note it in the report.
  • Asynchronous updates. Widgets that populate options lazily or announce via live regions need tester judgment on timing.
  • Archetype coverage. The blocks cover button, toggle button, checkbox, text input, modal dialog, menu button, and combobox. For other patterns, derive a new block from that pattern's keyboard interaction table in the APG pattern index (opens in new window).
  • No visual or color coverage. Contrast ratios, color-only status cues, and target size are outside these matrices.
  • Manual by construction. The tester is the instrument. These matrices produce a signed sheet, not a CI signal.

Related skills

a11y-violation-gate

Builds a CI gate that fails the build on **new** WCAG / a11y violations introduced by a PR while grandfathering pre-existing violations on a per-rule / per-page baseline. Aggregates verdicts from axe-core / pa11y / Lighthouse a11y / WAVE / IBM Equal Access scans. Use when a project has accumulated a11y debt and a strict "zero violations" gate would block every PR - the ratchet pattern lets the team ship while preventing regressions.

aria-authoring-patterns

Reference for the W3C ARIA Authoring Practices Guide (APG) - covers the 31 canonical interactive-widget patterns (Combobox, Dialog, Menu, Tabs, Tree, etc.), their required ARIA roles and states, the keyboard-interaction model per pattern, and the canonical-violations to watch for. Use when authoring a custom interactive widget that doesn't have a native HTML equivalent, or when reviewing one for ARIA correctness.

axe-a11y

Automated accessibility scanning across the five engines - axe-core (primary), pa11y, Lighthouse a11y, WAVE, and IBM Equal Access. Authors and runs axe-core scans via the `axe.run()` JavaScript API or the @axe-core/playwright / @axe-core/cli wrappers, parses `violations[]` into per-rule severity, configures rule disable / disable-by-tag patterns, and emits CI-gateable output; references/ carry the pa11y CLI (htmlcs + axe runners), Lighthouse CI `categories:accessibility` assertions, the WAVE API / overlay, and IBM Equal Access (Section 508) with their verified CLI / API / config. Use for any automated a11y scanner setup - axe-core for JS/TS UI test suites on every PR, and the references for CLI-only, Lighthouse-pipeline, WebAIM-branded, or Section 508 scanning.

wcag-color-contrast

Reference for WCAG 2.2 color-contrast conformance - covers SC 1.4.3 Contrast (Minimum, AA), 1.4.6 Contrast (Enhanced, AAA), 1.4.11 Non-text Contrast (AA), and 1.4.13 Content on Hover or Focus (AA) - with the canonical contrast ratios (4.5:1 normal text, 3:1 large text and UI components), measurement formula references, and bulk design-token checking patterns. Use when designing a color palette, reviewing a component for accessibility, or auditing existing CSS for contrast violations.

wcag-compliance-reporter

Builds a per-page WCAG 2.2 compliance score report by aggregating output from one or more accessibility scanners (axe-core / pa11y / lighthouse / WAVE / IBM Equal Access), pivoting violations by Success Criterion (1.4.3 contrast, 2.4.7 focus visible, etc.), grouping by conformance level (A / AA / AAA), reporting per-page coverage gaps explicitly (the "this page wasn't scanned" failure mode), and emitting both an executive summary and a per-page drill-down. Use after a multi-page accessibility scan - pa11y-ci, axe across a sitemap, lighthouse-batch - when the team needs a shareable conformance report rather than a per-page tool dump.

wcag-keyboard-navigation

Reference catalog for WCAG 2.2 keyboard-navigation conformance - covers SC 2.1.1 (Keyboard), 2.1.2 (No Keyboard Trap), 2.1.4 (Character Key Shortcuts), 2.4.3 (Focus Order), 2.4.7 (Focus Visible), 2.4.11/2.4.12 (Focus Not Obscured) - with conformance levels (A/AA), test scripts, and per-criterion failure patterns, plus the modal focus-trap / focus-management pattern (focus-on-open, Tab-cycle, inert, Escape-closes, restore-to-trigger, native `<dialog>`) in references/. Use when authoring or reviewing keyboard-only interaction support, or a modal / drawer / popover's focus management.