Core principle: treat accessibility semantics as a testable contract, not an audit checklist. A component's role, accessible name, focus behavior, state, and AT-visibility are API surface for assistive technology and role-based tests.
Use this skill when UI refactors keep preserving pixels while breaking semantics: an
unnamed modal, a styled div pretending to be a button, a combobox whose selected option
is no longer exposed, or tabs that visually switch panels without updating ARIA state.
The contract
Lock only semantics users and automation rely on:
- Role: query the element by the role users encounter (
dialog,button,menuitem,combobox,tab,tabpanel,status). Avoid querying implementation wrappers. - Accessible name: assert the label users hear, not incidental visible text. Prefer
aria-labelledby/native labels over brittlearia-labelcopies when a visible label already exists. - State: assert ARIA/native state that changes behavior (
aria-expanded,aria-selected,aria-checked, selectedoption,disabled, validation error text). Native form validity (:user-invalid,setCustomValidity,ValidityState) is owned byconstraint-validation-contracts. - Focus and keyboard path: assert focus lands where the pattern promises and can move with the expected keyboard controls.
- AT-visibility: role queries intentionally exclude elements hidden from the ARIA tree
(
aria-hidden,display:none,hidden). IfgetByRolecannot find it, many users cannot either.
This complements, not replaces, an audit runner. Axe-style checks catch broad violations; contract tests catch product-specific regressions after refactors.
Dialog-specific minimum
Dialog is still the canonical example because it fails loudly in production:
- The dialog container exposes
role="dialog"(oralertdialog). Addaria-modal="true"only when the dialog truly behaves modally for everyone: content outside is visually obscured and interaction outside is blocked. - The dialog has an accessible name:
aria-labelfor string-only titles, oraria-labelledbypointing at the rendered title element for visible titles. Unnamed dialogs violate WCAG SC 4.1.2 (Name, Role, Value). - Purely decorative wrappers (dim layers, positioning shells) get no interactive role.
Putting
role="button"on an overlay container is worse than it looks: ARIA buttons have presentational children, so descendants can disappear from the accessibility tree. - Focus moves into the dialog on open and, by default, returns to the trigger on close unless the APG-documented workflow has a more logical return target.
Test patterns
- Query by role + name, nothing else:
getByRole('dialog', { name: /delete project/i })(Playwright / Testing Library). This asserts role, accessible name, and AT-visibility in one locator. If a refactor dropsaria-labelledby, the locator fails for the right reason. - Sentinel spec: one data-driven test file that opens every modal or repeated widget in the app and asserts each resolves through its semantic contract. New unnamed modals, menus with missing item roles, or tabs without selected state fail before shipping.
- Don't lock tests to visual text that may be
aria-hidden. If a visual title is duplicated for layout (for example a sticky clone), hide only the clone from AT and pointaria-labelledbyat the canonical label. - i18n caveat: role+name queries hardcode copy. For localized apps, drive expected names from message IDs/fixtures or assert stable accessible labels only where copy is intentionally contractual.
- Auth form boundary: login/code/passkey forms need role/name/status assertions too;
use
frontend-auth-flow-contractsfor browser-auth-specific autofill, error, and lifecycle cases. - Static analysis is not enough: lint rules catch attribute-level mistakes; these tests verify that rendered DOM exposes the final accessible tree users receive.
Non-dialog widget examples
These are the cases that make the skill more than "modal name testing". Keep examples small and pattern-specific: one happy path plus the semantic state most likely to regress.
Menu button / menu items
await page.getByRole('button', { name: /more actions/i }).click();
await expect(page.getByRole('menuitem', { name: /archive/i })).toBeVisible();
await expect(page.getByRole('menuitem', { name: /delete/i })).toBeVisible();
Failure this catches: replacing a real menuitem with a clickable div, or hiding
menu text behind aria-hidden, so screen-reader and keyboard users lose the command list.
Combobox / listbox option
const assignee = page.getByRole('combobox', { name: /assignee/i });
await assignee.click();
await page.getByRole('option', { name: 'Ada Lovelace' }).click();
await assignee.click();
await expect(page.getByRole('option', { name: 'Ada Lovelace' })).toHaveAttribute('aria-selected', 'true');
Note: toHaveValue is for native <select>/<input> only and matches the option value,
not the visible label; for ARIA listbox/combobox widgets assert aria-selected on the option.
Scope this assertion: it holds for a select-only listbox popup, where the chosen option keeps
aria-selected='true' when the listbox reopens (APG Select-Only Combobox). Editable/autocomplete
comboboxes (aria-autocomplete='list'/'both') track the active option via aria-activedescendant
and may expose no aria-selected option — assert the combobox input value there, not aria-selected.
Failure this catches: custom select UI that looks selected but exposes no named combobox, no options, or no selected value to assistive tech.
Tabs / tabpanel pairing
await page.getByRole('tab', { name: /billing/i }).click();
await expect(page.getByRole('tab', { name: /billing/i })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tabpanel', { name: /billing/i })).toBeVisible();
Failure this catches: visual tab switching that never updates aria-selected, disconnects
the tab from its panel, or leaves multiple panels exposed.
Advanced contract notes
Virtualized listbox / combobox / table
Virtualization is not a defect by itself. Test the rendered contract users receive:
- Active option should be present in accessibility tree when
aria-activedescendantpoints at it, or component must use a documented pattern that keeps a stable active descendant. - Keyboard navigation should scroll the active row/option into view and keep the visible selection, focused input, and ARIA state aligned.
- Role tests should cover boundary movement: first item, last visible item, item just outside current window, filtered-empty state.
Reduced-motion and animated state changes
Use this skill for motion only when animation affects accessibility contract: focus moves after exit animation, hidden content remains focusable, live-region/status update delayed, or prefers-reduced-motion users still get motion required to understand or operate UI. Pure visual timing belongs in design/motion review, not a role/name/state finding.
Common defects
role="dialog"exists, but the dialog has no accessible name.- Visible heading is inside an
aria-hiddenwrapper, soaria-labelledbypoints to text AT cannot use. - A modal opens visually, but focus remains behind it or tab order escapes the modal.
- A wrapper gets a role while the interactive child keeps focus, creating duplicate or contradictory semantics.
role="presentation"/role="none"is applied to an interactive element and erases required semantics.- A menu, combobox, or tab component is implemented as clickable
divs without the APG role/state/keyboard contract. - Tests use
getByText/CSS selectors and keep passing after the accessible tree breaks.
PR-worthiness gate
A role/ARIA grep hit is not enough. Count a finding only when all three are true:
- User-operated surface: the element is a control or widget users must operate, not a decorative/layout wrapper.
- Rendered contract is broken: after rendering and interaction, the widget cannot be found by role/name, exposes the wrong state, loses focus order, or hides required content from the accessibility tree.
- Small regression test exists: a
getByRole(..., { name })/ keyboard-state test would fail before the patch and pass after it.
Reject weak findings:
- A missing
roleon a native element that already has the correct implicit role. - A wrapper with no ARIA because semantics are supplied by a child component or headless library.
- A visual navigation list that is not actually a tab interface; do not force APG tabs onto links.
- A pure copy/name preference where the role/state/focus contract already works.
Output shape
Report the operated surface, rendered role/name/state/focus evidence, user impact, smallest semantic or test change, and the regression assertion. Keep copy preferences and visual-only concerns separate from the accessibility contract.
Sources
- WCAG 2.x SC 4.1.2 Name, Role, Value: https://www.w3.org/WAI/WCAG21/Understanding/name-role-value
- WAI-ARIA Authoring Practices Guide patterns: Dialog (Modal), Menu Button, Combobox, Tabs: https://www.w3.org/WAI/ARIA/apg/patterns/
- WAI-ARIA
presentation/nonerole definition: https://www.w3.org/TR/wai-aria/#presentation - Testing Library
ByRoledocs: https://testing-library.com/docs/queries/byrole/ - Playwright
getByRoledocs: https://playwright.dev/docs/locators#locate-by-role - See
frontend-auth-flow-contractsfor auth-specific browser/autofill/error cases.