
Guide
Cursor rules for WCAG testing
Scope a .cursor/rules file to your components, turn jsx-a11y up to errors so the model reads its own mistakes, and run axe-core in pre-commit and CI.
Published August 8, 2026
Scope a .cursor/rules/*.mdc file to the components and styles it governs, turn eslint-plugin-jsx-a11y up to errors so the model sees its own mistakes in the editor, and run axe-core in a pre-commit hook and CI. Rules make the right markup likely; only the exit code makes the wrong markup impossible.
What belongs in the rule file, and what doesn't
The rule file holds the decisions — which element, which attribute, which threshold, and what to do instead. It does not hold the checking. A Cursor rule is injected context: it shapes the completion and cannot fail it, so anything you genuinely cannot ship without needs a second home outside the editor.
Scope matters as much as content. Rules live as .mdc files in .cursor/rules/, and the frontmatter decides when each one enters context — always, on matching globs, when the model decides the description looks relevant, or only by name. An accessibility ruleset is auto-attached work: it should arrive with the component files and stay out of every other request. (If you are still on a root .cursorrules file, the move to the directory format is described here.)
---
description: WCAG 2.2 AA rules for markup, focus, contrast, and motion. Load
when editing components, routes, or styles.
globs:
- "app/**/*.tsx"
- "components/**/*.tsx"
- "styles/**/*.css"
alwaysApply: false
---
# Accessibility (WCAG 2.2 AA — non-negotiable)
- **Native element first.** `<button>` for actions, `<a href>` for
navigation, `<label for>` for every input. A `<div onClick>` with
`role="button"` is a rewrite, not a fix: it must reimplement focus,
Enter, Space, and disabled state.
- **ARIA only when no native element does the job**, never to patch a wrong
one. Required on an icon-only button; noise on a button that already has
visible text, where it silently overrides what gets read aloud.
- **Focus is always visible.** `outline: none` only in the same change that
ships a `:focus-visible` indicator at 3:1 against both adjacent surfaces
(1.4.11, 2.4.7).
- **No positive `tabIndex`** — `0` to make a custom control focusable, `-1`
for a programmatic focus target (2.4.3).
- **Dialogs and menus:** focus moves in on open, is trapped while open,
returns to the trigger on close, and Escape closes (2.1.1, 2.1.2).
- **Interactive targets are 24x24 CSS px or have 24px of clear spacing**
(2.5.8). Icon buttons get padding, not a smaller icon.
- **One `<h1>`, no skipped levels**, headings name the section (1.3.1, 2.4.6).
- **Images:** meaningful ones get alt text saying what the image
communicates here; decorative ones get `alt=""`. A missing `alt` is
never correct.
- **State changes are announced.** Errors tie to the field with
`aria-describedby`; async results land in a live region (4.1.3).
- **Colour never carries meaning alone** (1.4.1).
- **Contrast is computed, not eyeballed**, compositing alpha text over its
real background: 4.5:1 body, 3:1 large text and UI boundaries.
- **Never tween opacity on text** — the audit samples mid-tween and fails
colour-contrast at random. Animate `transform` only.
- **Everything that moves respects `prefers-reduced-motion`.**
- **Layout reflows to a 320px viewport** with no horizontal scroll (1.4.10).
## When a check fails
Fix the markup. Never add `eslint-disable jsx-a11y`, never call
`disableRules`, never drop a route from the axe run, and never add an
`aria-label` whose only purpose is to quiet a warning. If you believe a
violation is a false positive, leave it failing and say why.Why the linter does more work here than the rule file
Because Cursor reads editor diagnostics. A rule is context the model may or may not weigh; a lint error is feedback that arrives on the line it just wrote, in the same loop, unprompted. For accessibility that difference is decisive, since the mistakes are the kind nothing else surfaces — the markup renders, the screenshot looks correct, and without the linter the session simply moves on.
eslint-config-next already ships a handful of jsx-a11y rules, mostly as warnings, and a warning is a thing an agent scrolls past. Extend the recommended set and promote the ones that map to a failure you would have to fix later.
{
"extends": ["next/core-web-vitals", "plugin:jsx-a11y/recommended"],
"rules": {
"jsx-a11y/no-static-element-interactions": "error",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/interactive-supports-focus": "error",
"jsx-a11y/label-has-associated-control": ["error", { "assert": "either" }],
"jsx-a11y/tabindex-no-positive": "error",
"jsx-a11y/no-noninteractive-tabindex": "error",
"jsx-a11y/anchor-is-valid": "error",
"jsx-a11y/no-redundant-roles": "error",
"jsx-a11y/media-has-caption": "error",
"jsx-a11y/no-autofocus": "error"
}
}What the linter cannot see is anything that depends on the rendered page: computed contrast, focus order across components, duplicate ids from two components meeting for the first time, an aria-controls pointing at an element in another file. That is the axe layer's job, and the split is worth being explicit about.
| Layer | Catches | Misses |
|---|---|---|
| Cursor rule (.mdc) | The choice, while it is being made: right element, replacement for a banned pattern, the project's own contrast tokens | Anything after the choice — it is context, not a check, and it cannot fail a build |
| jsx-a11y lint | Static markup mistakes on the line they are written: div-with-onClick, missing label, positive tabIndex | Anything computed at render: contrast, focus order, live regions, cross-component ids |
| axe in Playwright | The rendered DOM: contrast, names and roles, landmarks, duplicate ids, reflow at 320px | Meaning — whether alt text is right, whether an announcement makes sense, whether an error helps |
| Human pass | Everything above that involves judgement, plus the keyboard and screen-reader experience end to end | Nothing — but it only runs when a person runs it, so keep it short |
The gate: pre-commit for the cheap checks, CI for the real one
Order the checks by cost. Greps over the staged diff catch the common regressions in milliseconds; the axe run needs a build and a browser, so it belongs where a slow job is acceptable. A pre-commit hook that takes ninety seconds is a pre-commit hook someone bypasses with --no-verify by Thursday.
#!/usr/bin/env sh
# The disable comment is the accessibility equivalent of raising the budget
# to meet the bundle: message gone, defect intact.
if git diff --cached -- '*.tsx' '*.ts' | grep -qE '^\+.*eslint-disable.*jsx-a11y'; then
echo "✗ new jsx-a11y disable. Fix the markup, or get a human to sign it off."
exit 1
fi
if git diff --cached -- '*.css' '*.tsx' | grep -qE '^\+.*outline:\s*(none|0)'; then
echo "✗ outline removed. Ship a :focus-visible indicator in the same change."
exit 1
fi
npx lint-staged || exit 1 # jsx-a11y errors block the commitname: a11y
on: pull_request
jobs:
axe:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npm run test:a11y # @axe-core/playwright over every public routeThe axe spec itself is short — load each route, run the WCAG 2.0 to 2.2 AA tag set, assert the violation list is empty, and add a reflow check at a 320px viewport for 1.4.10. It is written out in full in the Claude Code version of this setup, and nothing in it is editor-specific.
Measuring from inside the editor
Cursor supports MCP servers per-project in .cursor/mcp.json, and a browser-driving server is the one worth having, because it turns “is this accessible?” from a question the model answers from the source into one it answers from the page — including the accessibility tree, which is the thing you actually want it reading.
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest"]
}
}
}One habit is worth building on top of it: after any UI change, have the model tab through the page and report the first control with no visible focus indicator, then emulate prefers-reduced-motion and report anything still moving. Both are mechanical, both are things nobody checks by hand, and both are how the palette rules on this site survived contact with an animated headline — the story of which is in the note on tweening opacity.
Treat the automated pass as a floor rather than a result. Designing to the standard, so the checks have little left to find, is part of how the front end gets built rather than a stage at the end.
Questions
Should the accessibility rule be alwaysApply: true?
No. Scope it to the globs where UI is written — components, routes, and stylesheets — and it loads exactly when it's relevant. An always-on rule taxes every request, including the ones about a build script, and a ruleset that is present for everything gets skimmed like boilerplate.
Why bother with jsx-a11y if the rule file says the same things?
Because the linter produces errors and the rule file produces intentions. Cursor reads diagnostics from the editor, so a lint error is feedback the model gets back in the same loop it made the mistake in — which is the closest thing to a gate you get without leaving the editor.
The agent keeps adding eslint-disable comments. How do I stop that?
Say so in the rule file and grep for it in the pre-commit hook. A disable comment is the accessibility equivalent of raising the budget to meet the bundle: it resolves the message and keeps the defect. Any new jsx-a11y disable should fail the commit and require a human to sign it off.
Do I still need a screen reader if axe passes on every route?
Yes. Automated rules cover roughly half of WCAG at the optimistic end of the published figures, and none of the half that involves meaning — whether alt text is right, whether an announcement makes sense, whether an error tells you how to recover. Budget ten minutes with VoiceOver per meaningful UI change.
Related notes
- Claude Code rules for WCAG testing
- Migrate from .cursorrules to .cursor/rules
- Cursor rules for Core Web Vitals
More on web development
- Claude Code rules for Elementor sites
- Animating hero text without layout shift
- Never tween opacity on animated text
- next/font: why display optional beats swap
Related service: Web Development
Want this kind of engineering on your project?
Tall Karol takes on fractional and project-based engagements for startups and agencies.
Book a working session