Workflow · WCAG 2.2 AA · WAI-ARIA 1.2 · axe-core 4.12
Accessibility (a11y)
Semantic HTML, keyboard, contrast — WCAG by default.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are building web UIs that meet WCAG 2.2 Level AA. "Good" means every feature is perceivable, operable, understandable and robust for keyboard, screen reader, zoom and reduced-motion users — verified, not assumed. Semantic HTML is the default; ARIA is the exception.
Stack
- Standard: WCAG 2.2 (W3C Recommendation), target Level AA. WCAG 3.0 is a Working Draft — do not rely on it. Know the 2.2-new criteria: 2.4.11 Focus Not Obscured, 2.5.7 Dragging Movements, 2.5.8 Target Size (min 24×24 CSS px), 3.2.6 Consistent Help, 3.3.7 Redundant Entry, 3.3.8 Accessible Authentication.
- Semantics: HTML Living Standard elements first. WAI-ARIA 1.2 (the stable Recommendation) for roles/states; ARIA 1.3 is still a Working Draft — do not ship
aria-descriptionor thecomment/suggestionroles as load-bearing. - Naming model: Follow the ARIA Authoring Practices Guide (APG) patterns for composite widgets (combobox, tabs, menu, disclosure, dialog). Copy the keyboard interaction tables exactly.
- Automated engine:
axe-core4.12.1 everywhere — via@axe-core/playwright4.12.1 in E2E and axe-core directly in unit tests. - Lint:
eslint-plugin-jsx-a11y6.10.2 (latest release; flat config, runs under ESLint 9 and the current ESLint 10 major — official ESLint 10 peer-dep support is still in-flight upstream) for JSX stacks;eslint-plugin-vuejs-accessibilityfor Vue,svelte-checkfor Svelte. - Manual tools: axe DevTools browser extension, Playwright 1.61, NVDA+Firefox (Windows), VoiceOver+Safari (macOS/iOS), Chrome/Firefox keyboard-only, browser 400% zoom,
forced-colorsemulation.
Project conventions
- Colocate a11y checks with components:
Button.tsx+Button.a11y.test.tsx. Run axe on the rendered component, not on JSDOM strings. - Keep a single global visually-hidden utility (never re-invent per component):
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.sr-onlyhides visually but keeps content in the accessibility tree. Never use it for interactive controls a sighted user also needs. - Set
<html lang="…">(anddirfor RTL) in the root layout. Setlangon any inline foreign-language passage (WCAG 3.1.1/3.1.2). - Enable the strict a11y lint set in flat config and treat as errors:
import jsxA11y from 'eslint-plugin-jsx-a11y'; export default [ jsxA11y.flatConfigs.strict ]; - One
<h1>per page/route. Establish the landmark skeleton once in the layout:header > nav,main#main-content,footer. Never nest interactive controls.
Semantic HTML first
Reach for the native element before any div+ARIA. Native elements give you role, keyboard behavior, focus and states for free.
- Actions →
<button type="button">. Navigation (changes URL) →<a href>. A<button>inside a<form>istype="submit"by default — settype="button"for non-submitting actions. - Never build a control from
<div onClick>/<span onClick>. It has no role, no keyboard, no focus. This is the single most common defect — forbid it. - Ordered headings
<h1>→<h6>with no skipped levels; headings describe structure, not styling. Style with CSS/classes, not by picking a heading level for its size. - Lists (
<ul>/<ol>/<dl>) for any collection;<nav>for nav groups;<table>with<th scope>/<caption>for tabular data (never for layout). - Landmarks: exactly one
<main>; give each repeated<nav>/<aside>a distinguishingaria-label(e.g.aria-label="Primary",aria-label="Breadcrumb"). - Disclosure →
<details>/<summary>. Modal → native<dialog>+dialog.showModal()(gives focus trap,inertbackground, Esc-to-close for free). - Every form control has a programmatic label (see Forms). Every
<img>has analt. Every<iframe>has atitle.
ARIA — only to fill gaps
- First rule of ARIA: don't use ARIA if a native element does the job. ARIA changes semantics but adds zero behavior — you still wire keyboard, focus and state yourself.
- Prefer a real
<label>/text content overaria-label. Usearia-labelledby/aria-describedbyto point at existing visible text (space-separated ID list) before inventing hidden strings. - Never put
aria-label/aria-labelledbyon a non-interactive, non-landmark, non-roleelement (div,span,p) — it is ignored. Give icon-only buttons a name:<button aria-label="Close">…svg…</button>with the<svg>markedaria-hidden="true"/focusable="false". - Keep roles and states valid and in sync:
aria-expandedon the trigger of anything collapsible;aria-pressedfor toggles;aria-current="page"for the active nav link;aria-selectedon tabs. Update them on every state change. - Never apply
aria-hidden="true"to a focusable element or an ancestor of one — it removes it from the a11y tree while keyboard focus still lands on it (a serious trap). Use theinertattribute to remove background content from both. - Don't set a
rolethat contradicts the element (<a role="button">needs Space-key handling and<button>semantics — just use<button>). Don't add redundant roles (<button role="button">,<nav role="navigation">); addrole="list"only when CSSlist-style:nonehas stripped list semantics in Safari. - Announce async changes with live regions present in the DOM before they update:
role="status"(polite) for confirmations,role="alert"(assertive) for errors. Don't toggledisplay:noneon the region — swap its text.
Keyboard & focus
- Everything a mouse can do, the keyboard must do (WCAG 2.1.1), with no keyboard trap (2.1.2) except an intentional modal that Esc closes.
- DOM order = reading/tab order. Achieve visual reordering with CSS (
order,grid) without breaking DOM order.tabindexis only ever0or-1. Positivetabindexis banned — it destroys natural order. - Keep a visible focus indicator; never
outline:nonewithout a replacement. Style focus with:focus-visibleso pointer clicks don't show a ring but keyboard does:
Ensure the indicator meets 3:1 contrast against adjacent colors (1.4.11) and stays visible / not covered by sticky headers (2.4.11).:focus-visible{outline:2px solid;outline-offset:2px} - First focusable element is a skip link to
#main-content:
Make it visible on focus; the target (<a class="skip-link" href="#main-content">Skip to content</a><main id="main-content">or atabindex="-1"heading) must be focusable. - SPA route changes: browser focus does not reset. After navigation, move focus to the new page's
<h1>(or atabindex="-1"region) and/or announce the new title via a live region — otherwise screen-reader/keyboard users are stranded. - Modals/dialogs: prefer native
<dialog>. If you must userole="dialog" aria-modal="true", you must: move focus in on open, trap Tab within, restore focus to the invoking element on close, close on Esc, andinertthe rest of the page. - Composite widgets (tabs, menu, listbox, grid) use roving tabindex or
aria-activedescendantand Arrow-key navigation per the APG — a tab list is one Tab stop, not one per tab. - Pointer-only gestures need a single-pointer alternative: any drag interaction (reorder, slider) also needs click/arrow controls (2.5.7). Interactive targets are ≥24×24 CSS px (2.5.8) unless inline in text.
Images & media
- Meaningful image → concise
altconveying purpose/content, not "image of". A linked image'saltdescribes the destination/action. - Decorative/redundant image →
alt=""(empty, present) so screen readers skip it. CSS background images must not carry information. - Complex images (charts) → short
alt+ a longer text alternative nearby or viaaria-describedby.<figure>/<figcaption>for captioned content. <svg>used as an icon inside a labeled control:aria-hidden="true". Standalone informative<svg>:role="img"+<title>(first child) oraria-label.- Video needs synchronized captions (
<track kind="captions">, 1.2.2) and audio-only needs a transcript. Provide audio description for meaningful visual-only content (1.2.5). - No autoplay with sound; any media/animation over 5s must be pausable (2.2.2). Nothing flashes more than 3× per second (2.3.1).
Color & contrast
- Body/normal text ≥ 4.5:1; large text (≥24px, or ≥18.66px/14pt bold) ≥ 3:1 (1.4.3). UI component boundaries, icons and graph data (non-text) ≥ 3:1 (1.4.11).
- Never convey meaning by color alone (1.4.1): pair color with text, icon, underline or pattern. Links in body text need a non-color cue (underline) unless they meet 3:1 against surrounding text and have a distinct hover/focus state.
- Support 400% zoom / 320px reflow with no loss of content or horizontal scroll (1.4.10) and honor user text-spacing overrides (1.4.12) — use relative units (
rem), avoid fixed heights on text containers. - Respect
forced-colors(Windows High Contrast): don't remove outlines, use system color keywords (ButtonText,Canvas,LinkText), and re-assert focus visibility withforced-color-adjustonly when necessary. Test withprefers-contrast. - Do not disable browser zoom: never ship
<meta name="viewport" … maximum-scale=1, user-scalable=no>.
Forms
- Every input/select/textarea has a programmatically associated label:
<label for="email">+id="email", or wrap the control in<label>. Placeholder is not a label (it vanishes on input and often fails contrast). - Group related radios/checkboxes in
<fieldset>with a<legend>. Use the nativerequiredattribute (not onlyaria-required) andtype="email"/inputmode/autocompletetokens (1.3.5, e.g.autocomplete="email","current-password"). - Errors: set
aria-invalid="true"on the field, link the message witharia-describedby="email-err", and describe how to fix it in text — not color alone (3.3.1/3.3.3). On submit, move focus to the first invalid field or an error summary (role="alert"or a focused heading) listing links to each field. - Don't re-ask for info already provided in the same process (3.3.7) — offer auto-fill/carry-forward. Provide Consistent Help placement across pages (3.2.6).
- Accessible authentication (3.3.8): don't require a cognitive test (transcribe/solve/remember) with no alternative. Allow password managers and paste in all fields; never block paste on password/OTP inputs. Prefer OTP
autocomplete="one-time-code". - Don't disable the submit button as the sole error signal; don't validate
onChangein a way that announces errors while the user is still typing.
Motion & responsive
- Wrap non-essential animation, parallax, auto-scrolling and transitions in a reduced-motion guard (2.3.3):
@media (prefers-reduced-motion: reduce){ *,::before,::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important} } - Gate JS-driven motion (scroll animation, autoplay carousels) on
matchMedia('(prefers-reduced-motion: reduce)').matchesand provide play/pause. Keep essential motion (e.g. loading) subtle. - Layout must work at 200%/400% zoom and 320px width without clipping; touch targets ≥24px (44px recommended for primary actions).
Testing
Automated axe catches only ~30–40% of WCAG issues — it is a floor, never proof. Combine four layers:
- Lint (pre-commit):
eslint-plugin-jsx-a11ystrict, zero warnings. - Unit/component (Vitest/Jest + axe-core): render the real component, assert no violations. Prefer calling axe-core directly (the
vitest-axewrapper is stale):
axe tags do not cascade — each tag runs only its own rules, so omitimport { render } from '@testing-library/react'; import axe from 'axe-core'; const { container } = render(<Field label="Email" />); // full WCAG 2.2 AA conformance set = 2.0/2.1/2.2 at both A and AA. const { violations } = await axe.run(container, { runOnly: ['wcag2a','wcag2aa','wcag21a','wcag21aa','wcag22a','wcag22aa'] }); expect(violations).toEqual([]);wcag22aand you silently drop the 2.2 Level-A criteria (3.2.6 Consistent Help, 3.3.7 Redundant Entry). Use the same six tags in every layer (unit and E2E) so coverage never diverges. Use Testing Library queries by role/name (getByRole('button', { name: 'Save' })) — a failing role/name query is itself an a11y bug. - E2E (Playwright + @axe-core/playwright): scan real pages and states (modal open, error shown):
Also script a keyboard-only pass: Tab through the whole flow, assert focus order and that the focused element is always visible.import AxeBuilder from '@axe-core/playwright'; const results = await new AxeBuilder({ page }) .withTags(['wcag2a','wcag2aa','wcag21a','wcag21aa','wcag22a','wcag22aa']).analyze(); expect(results.violations).toEqual([]); - Manual (required before "done"): unplug the mouse and complete the flow; run one screen reader (NVDA+Firefox or VoiceOver+Safari) on new interactive UI; test at 400% zoom and in forced-colors mode. Automated tools cannot judge alt quality, focus logic, or announcement sense.
Security
- Every
target="_blank"link carriesrel="noopener noreferrer"(reverse-tabnabbing). - Sanitize any user-supplied string before it becomes an accessible name,
aria-label, live-region text, ordangerouslySetInnerHTML— a11y attributes are an XSS sink too. - Don't put secrets/PII into
aria-label,title, or live regions that a screen reader will read aloud in shared spaces; mask them like the visual UI does. - Keep the accessibility tree honest: never hide security-relevant state (session expiry, auth errors) from AT while showing it visually.
- CAPTCHA/bot checks must have a non-cognitive, non-visual-only accessible path (ties 3.3.8 to your auth security).
Do
- Start from a native element; reach for ARIA only when the platform has no equivalent.
- Label every control; give every image an
alt(empty for decorative); give every icon-button an accessible name. - Keep DOM order = visual/tab order; use
tabindex="0"/"-1"only. - Keep a visible
:focus-visibleindicator at ≥3:1 contrast; add a skip link. - Manage focus on modal open/close and SPA route changes; restore focus on close.
- Meet 4.5:1 text / 3:1 non-text contrast and always pair color with a second cue.
- Link form errors via
aria-describedby+aria-invalid; move focus to the first error. - Honor
prefers-reduced-motionandforced-colors; support 400% zoom. - Test with keyboard, a screen reader, and axe on real rendered UI.
Avoid
<div>/<span>withonClickas a button → use<button>. Fake links → use<a href>.- Positive
tabindex(e.g.tabindex="3") → use natural DOM order +0/-1. outline:nonewith no replacement → use:focus-visiblewith a visible ring.aria-hidden="true"on focusable/interactive content → useinertfor backgrounded regions.- Placeholder-as-label, and label-less inputs → real
<label for>. - Redundant/broken ARIA (
role="button"on<button>,aria-labelledbypointing at a missing/empty ID) → remove or fix. - Color/icon-only status ("red means error") → add text.
maximum-scale=1/user-scalable=no→ never block zoom.alt="image"/omitted alt, and<img>with meaning as CSS background.- Custom widgets without the APG keyboard model (Arrow keys, Home/End, Esc) → follow APG.
- Trusting a green axe/Lighthouse score as "accessible" → it is a floor, not proof.
When you code
- Ship the smallest correct diff. When adding any interactive element, in the same change wire its role, accessible name, keyboard handling, focus behavior and states — never leave a11y as a follow-up.
- Before finishing: run typecheck,
eslint(a11y rules must pass), unit axe tests, and a manual keyboard tab-through of the affected UI. Report what you verified. - If a design spec would force an inaccessible pattern (color-only meaning, sub-24px targets, non-keyboard gesture, contrast below AA, disabling zoom), flag it and propose the accessible alternative before implementing — do not silently ship the violation.
- When unsure which APG pattern or role applies, ask or cite the specific WCAG SC / APG pattern you are implementing rather than guessing an ARIA role.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on WCAG 2.2 AA · WAI-ARIA 1.2 · axe-core 4.12.