Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Workflow · WCAG 2.2 AA · WAI-ARIA 1.2 · axe-core 4.12

Accessibility (a11y)

Semantic HTML, keyboard, contrast — WCAG by default.

accessibilitya11ywcag

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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-description or the comment/suggestion roles 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-core 4.12.1 everywhere — via @axe-core/playwright 4.12.1 in E2E and axe-core directly in unit tests.
  • Lint: eslint-plugin-jsx-a11y 6.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-accessibility for Vue, svelte-check for 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-colors emulation.

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-only hides visually but keeps content in the accessibility tree. Never use it for interactive controls a sighted user also needs.
  • Set <html lang="…"> (and dir for RTL) in the root layout. Set lang on 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> is type="submit" by default — set type="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 distinguishing aria-label (e.g. aria-label="Primary", aria-label="Breadcrumb").
  • Disclosure → <details>/<summary>. Modal → native <dialog> + dialog.showModal() (gives focus trap, inert background, Esc-to-close for free).
  • Every form control has a programmatic label (see Forms). Every <img> has an alt. Every <iframe> has a title.

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 over aria-label. Use aria-labelledby/aria-describedby to point at existing visible text (space-separated ID list) before inventing hidden strings.
  • Never put aria-label/aria-labelledby on a non-interactive, non-landmark, non-role element (div, span, p) — it is ignored. Give icon-only buttons a name: <button aria-label="Close">…svg…</button> with the <svg> marked aria-hidden="true" / focusable="false".
  • Keep roles and states valid and in sync: aria-expanded on the trigger of anything collapsible; aria-pressed for toggles; aria-current="page" for the active nav link; aria-selected on 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 the inert attribute to remove background content from both.
  • Don't set a role that 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">); add role="list" only when CSS list-style:none has 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 toggle display:none on 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. tabindex is only ever 0 or -1. Positive tabindex is banned — it destroys natural order.
  • Keep a visible focus indicator; never outline:none without a replacement. Style focus with :focus-visible so pointer clicks don't show a ring but keyboard does:
    :focus-visible{outline:2px solid;outline-offset:2px}
    
    Ensure the indicator meets 3:1 contrast against adjacent colors (1.4.11) and stays visible / not covered by sticky headers (2.4.11).
  • First focusable element is a skip link to #main-content:
    <a class="skip-link" href="#main-content">Skip to content</a>
    
    Make it visible on focus; the target (<main id="main-content"> or a tabindex="-1" heading) must be focusable.
  • SPA route changes: browser focus does not reset. After navigation, move focus to the new page's <h1> (or a tabindex="-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 use role="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, and inert the rest of the page.
  • Composite widgets (tabs, menu, listbox, grid) use roving tabindex or aria-activedescendant and 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 alt conveying purpose/content, not "image of". A linked image's alt describes 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 via aria-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) or aria-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 with forced-color-adjust only when necessary. Test with prefers-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 native required attribute (not only aria-required) and type="email"/inputmode/autocomplete tokens (1.3.5, e.g. autocomplete="email", "current-password").
  • Errors: set aria-invalid="true" on the field, link the message with aria-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 onChange in 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)').matches and 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:

  1. Lint (pre-commit): eslint-plugin-jsx-a11y strict, zero warnings.
  2. Unit/component (Vitest/Jest + axe-core): render the real component, assert no violations. Prefer calling axe-core directly (the vitest-axe wrapper is stale):
    import { 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([]);
    
    axe tags do not cascade — each tag runs only its own rules, so omit wcag22a and 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.
  3. E2E (Playwright + @axe-core/playwright): scan real pages and states (modal open, error shown):
    import AxeBuilder from '@axe-core/playwright';
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a','wcag2aa','wcag21a','wcag21aa','wcag22a','wcag22aa']).analyze();
    expect(results.violations).toEqual([]);
    
    Also script a keyboard-only pass: Tab through the whole flow, assert focus order and that the focused element is always visible.
  4. 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 carries rel="noopener noreferrer" (reverse-tabnabbing).
  • Sanitize any user-supplied string before it becomes an accessible name, aria-label, live-region text, or dangerouslySetInnerHTML — 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-visible indicator 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-motion and forced-colors; support 400% zoom.
  • Test with keyboard, a screen reader, and axe on real rendered UI.

Avoid

  • <div>/<span> with onClick as a button → use <button>. Fake links → use <a href>.
  • Positive tabindex (e.g. tabindex="3") → use natural DOM order + 0/-1.
  • outline:none with no replacement → use :focus-visible with a visible ring.
  • aria-hidden="true" on focusable/interactive content → use inert for backgrounded regions.
  • Placeholder-as-label, and label-less inputs → real <label for>.
  • Redundant/broken ARIA (role="button" on <button>, aria-labelledby pointing 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.

Back to top ↑