What agents get wrong.
AI agents can recite WCAG success criteria from memory and still ship interfaces that fail a five-minute keyboard test. The gap is not knowledge. It is incentives. Accessibility costs tokens, and nothing in a default prompt rewards it.
The same mistakes show up across Cursor, Claude Code, Codex, and v0:
- Div soup with ARIA patches. A
<div role="button" tabindex="0">instead of a<button>. It looks fine, breaks Enter/Space handling, and confuses every screen reader. - Placeholder-as-label. Inputs with
placeholder="Email"and no<label>. Placeholders disappear on focus and are not announced reliably. - Icon-only controls with no accessible name. A trash-can SVG with no text, no
aria-label, and no visually hidden span. VoiceOver reads "button". - Custom modals without focus management. Overlays that trap scroll but not Tab. Focus escapes to the page behind. Escape key does nothing.
- Contrast that fails in dark mode. Agents love
text-gray-400onbg-gray-900. That is roughly 2.8:1. WCAG AA requires 4.5:1 for normal text. - Tap targets under 24px. WCAG 2.2 added 2.5.8 Target Size (Minimum): interactive targets need at least 24×24 CSS pixels, or equivalent spacing. Agents shrink icon buttons to 16px because it looks cleaner.
- Sticky headers covering focused elements. 2.4.11 Focus Not Obscured (Minimum) means focused controls cannot be fully hidden by fixed nav bars, cookie banners, or chat widgets.
The baseline is WCAG 2.2 Level AA, not 2.1. Same core requirements, plus criteria that matter on modern SPAs: focus visibility under overlays, minimum target size, accessible authentication flows, and consistent help placement (3.2.6 Consistent Help).
Contrast ratios to memorize: 4.5:1 for normal text, 3:1 for large text (18pt+ or 14pt bold+), and 3:1 for non-text UI components like icons, borders, and focus indicators.
Install the frontend-accessibility skill in your agent so these rules persist across sessions. Pair it with Guide 13 for UI prompt patterns and Guide 09 for the broader test strategy.
Lighthouse accessibility score is a rough heuristic, not a WCAG audit. A page can score 95 and still fail keyboard navigation, meaningful alt text, and form error association. Use axe-core for automated checks and your keyboard for the rest.
The a11y prompt contract.
One paragraph in AGENTS.md beats ten reminders per session. Paste this block at the repo root and point every UI task at it.
## Accessibility (WCAG 2.2 Level AA, mandatory) - Semantic HTML first: <button>, <a>, <label>, <dialog>, <details> - No div/span with role="button" unless native element is impossible - Every input has a visible <label> or aria-labelledby - Color contrast: 4.5:1 normal text, 3:1 large text, 3:1 UI components - Focus visible; focused element not fully obscured (2.4.11) - Interactive targets ≥ 24×24 CSS px or equivalent spacing (2.5.8) - Modals: native <dialog> with showModal(), or Popover API - Auth flows: no cognitive function test only (3.3.8) - Help links in consistent location across pages (3.2.6) - Run axe-core before marking UI tasks done; zero critical/serious violations
For individual tasks, prepend a one-liner so the agent cannot skip it:
Build the checkout form component. WCAG 2.2 AA required: native labels, keyboard-operable, 4.5:1 contrast, 24px targets, errors linked with aria-describedby, must pass axe-core with zero critical violations. Use <button> not div buttons.
See Guide 13 for the full component-first prompt shape. Accessibility belongs in the same spec block as states, tokens, and types, not in a follow-up "now add a11y" message.
Keyboard, focus, and ARIA.
The fix for most agent output is deletion, not addition. Remove the custom widget. Use the platform primitive. Native elements carry keyboard behavior, focus semantics, and screen reader mappings for free.
Prefer semantic HTML over ARIA. The first rule of ARIA: do not use ARIA if a native element exists. Agents reach for role="tablist" when <details> or anchor links would work.
<button id="open-settings">Settings</button> <dialog id="settings-dialog" aria-labelledby="settings-title"> <h2 id="settings-title">Settings</h2> <!-- form content --> <form method="dialog"> <button value="cancel">Cancel</button> <button value="save">Save</button> </form> </dialog> <script> const dlg = document.getElementById('settings-dialog'); document.getElementById('open-settings').addEventListener('click', () => { dlg.showModal(); // traps focus, Escape closes, inert backdrop }); </script>
Popover API for lightweight overlays (menus, tooltips
that behave like popovers). The browser handles focus and
popover light-dismiss. Agents rarely know this API exists.
Teach it explicitly.
<button popovertarget="user-menu" aria-expanded="false"> Account </button> <div id="user-menu" popover> <a href="/profile">Profile</a> <a href="/logout">Log out</a> </div>
Focus rules that agents violate constantly:
- Never use
tabindexvalues above 0. They hijack tab order. - Visible focus styles on every interactive element.
outline: nonewithout a replacement is a WCAG failure. - Under fixed headers, scroll focused elements into view or add scroll-padding so 2.4.11 passes.
- Return focus to the trigger when a dialog closes.
The frontend-accessibility skill has roving tabindex patterns for tabs and menus when native elements are not enough.
Forms and errors.
Agents generate pretty forms and forget the failure path. WCAG cares more about what happens when validation fails than about border-radius.
Non-negotiables for every form an agent touches:
- Visible labels tied with
for/idoraria-labelledby. - Required fields marked in text, not color alone. Use
aria-required="true"or therequiredattribute. - Error text in prose, linked via
aria-describedbyandaria-invalid="true"on the field. - Error summary at the top on submit failure, with links or focus moves to the first invalid field.
- Authentication must not rely on memory alone. 3.3.8 Accessible Authentication (Minimum) allows copy-paste, password managers, and WebAuthn. Do not block paste on password fields.
<label for="email">Email</label> <input type="email" id="email" name="email" aria-describedby="email-error" aria-invalid="true" autocomplete="email" /> <p id="email-error" role="alert"> Enter a valid email address, for example [email protected]. </p>
For 3.2.6 Consistent Help, put contact, FAQ, and support links in the same place on every page (footer or header). Agents scatter "Need help?" links randomly across templates.
Testing with axe-core.
Automated accessibility testing catches the boring failures agents repeat: missing labels, bad contrast, broken ARIA. Three tools, one engine: axe-core.
1. Browser extension for spot checks during dev. Deque axe DevTools or the open-source extension. Run it on every new page before you merge.
2. @axe-core/playwright for e2e. Scan rendered pages in CI after your app mounts. Catches issues unit tests miss because they never render CSS.
3. jest-axe for component tests. Fast feedback on individual React/Vue/Svelte components without launching a browser.
npm install -D @axe-core/playwright // e2e/a11y.spec.ts import { test, expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; test('home page has no critical a11y violations', async ({ page }) => { await page.goto('/'); const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) .analyze(); expect(results.violations.filter(v => ['critical', 'serious'].includes(v.impact) )).toEqual([]); });
npm install -D jest-axe import { render } from '@testing-library/react'; import { axe, toHaveNoViolations } from 'jest-axe'; import { LoginForm } from './LoginForm'; expect.extend(toHaveNoViolations); test('LoginForm is accessible', async () => { const { container } = render(<LoginForm />); const results = await axe(container); expect(results).toHaveNoViolations(); });
axe cannot judge alt text quality or logical reading order. After automated scans, tab through the page once with your keyboard. That 10-minute pass catches what machines miss. See Guide 09 for where a11y tests fit in the broader pyramid.
The CI gate.
Prompts drift. CI does not. Add an accessibility job that runs on
every PR touching src/, app/, or
components/. Fail on critical and serious axe violations.
Warn on moderate until you burn down the backlog.
name: Accessibility on: pull_request: paths: ['src/**', 'app/**', 'components/**'] jobs: axe: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '22' } - run: npm ci - run: npm run build - run: npx playwright install --with-deps chromium - run: npm run test:a11y
Pair the CI job with the AGENTS.md contract and the frontend-accessibility skill. When the agent knows CI will reject div-buttons, it stops generating them. Same pattern as type-check gates in Guide 01.
Optional but high leverage: a pre-commit hook that runs
jest-axe on changed component files. Fast feedback before
the PR even opens.
Live: WCAG 2.2 AA audit checklist.
Twelve checks before an AI-built UI meets real users. Tap each one as you confirm it. Your score saves in this browser so you can resume the audit tomorrow.
0–5: do not ship to users. 6–9: ok for internal demos. 10–11: ok for public beta. 12: ok for production and compliance conversations.
Common ways this still goes wrong.
Agents add aria-label to unlabeled icon buttons instead of visible text or a proper label element. Screen reader users get "Delete" with no context about what is being deleted. Fix the design, not the attribute.
CI passes axe but keyboard users cannot reach the submit button because a z-index stack traps focus in a hidden layer. Run one manual Tab walkthrough per feature. axe will never catch that.
Third-party "accessibility plugins" that add a toolbar to your site do not fix WCAG violations and often create new ones. They are a liability, not a shortcut. Fix the HTML.
"Ship the feature, then add accessibility" doubles the work and triples the regressions. Put the contract in AGENTS.md before the first UI prompt. See Guide 13 for getting the spec right the first time.