Accessibility
How to fix accessibility issues with Cursor
Write Cursor project rules for accessibility, give the agent findings one at a time, and check each fix with axe and a keyboard pass before you merge.
Last updated
To fix accessibility issues with Cursor, write your accessibility standards as a project rule in .cursor/rules/, made of rules Agent can test itself against: “every form field has a visible, associated label”, not “make it accessible”. Then give Agent one finding at a time, make it run an axe-core check in the terminal before it finishes, review the diff, and re-check the deployed page.
This guide gives you a rules file to copy, a check Agent can run, a hook that sends failures back to Agent, and three fixes from finding to check. It applies our AI agent fix workflow with Cursor’s own mechanisms.
What you need first
- A list of findings. Each one should say where (page, screen width, element), what is wrong (the rule or the person who found it, and the measured value) and which WCAG criterion it falls under. Agent works from that, so vague findings make vague fixes.
- Playwright and
@axe-core/playwrightinstalled as dev dependencies, for the check below. Any test command that fails with a non-zero exit code will do instead. - A branch for the work, with one commit per finding, so a fix that breaks something can be reverted alone.
Every Cursor feature below comes from Cursor’s documentation, read on 2 November 2026. Cursor has renamed several modes and settings over time, so check the linked pages if a name has changed. This guide is about the sites you build with Cursor, not the mouse-pointer settings that share the search term.
Write an accessibility project rule
Cursor’s project rules live in .cursor/rules/ as .mdc files and are version-controlled. The extension matters: a plain .md file in that folder is ignored, because it has no frontmatter. Three frontmatter fields, description, globs and alwaysApply, decide when a rule reaches Agent:
Save this as .cursor/rules/accessibility.mdc:
---globs: src/**/*.tsx, src/**/*.jsx, src/**/*.cssalwaysApply: false---# AccessibilityEvery rule here can be checked. If a rule conflicts with the task, stop and ask.## Elements- Actions are <button type="button">. Navigation is <a href>. Never put a click handler on a <div> or <span>.- Form fields have a <label for> with visible text. A placeholder is not a label.- Modal dialogs use <dialog> opened with showModal().## Names- Every control has an accessible name. Icon-only buttons get aria-label, and their icon gets aria-hidden="true".- If a control has visible text, do not add an aria-label that changes its name.- Ask for alt text on content images; do not invent it. Decorative images get alt="".## Keyboard and focus- Everything that works with a mouse works with Tab, Enter, Space and Escape.- No outline: none unless a :focus-visible style with at least 3:1 contrast replaces it.- role="tablist", "menu", "listbox" or "grid" needs the arrow-key behaviour from the ARIA Authoring Practices Guide. If you are not building that, do not use the role.- Opening a dialog moves focus into it. Closing it returns focus to the control that opened it.- Sticky headers must not cover focused elements: keep scroll-padding-top in step with the header height.## ARIA- Use ARIA only when no native element does the job.- aria-controls, aria-labelledby and aria-describedby point at ids that exist.- Never put aria-hidden="true" on a focusable element or on anything containing one.## Colour and size- Colours come from src/styles/tokens.css. Do not add hex values or change a token. If no token gives 4.5:1 for text, stop and ask.- Pointer targets are at least 24 by 24 CSS pixels, or spaced as WCAG 2.5.8 allows.## Done when- Run `npm run test:a11y` before you finish and paste the summary.- List what the check cannot confirm: keyboard use, focus order, screen reader output.This rule uses globs alone, so it loads whenever a component, page or stylesheet is in context. Cursor’s table lists a rule with globs as auto-attached and does not say a description changes that, so leave description out. Keep rules under 500 lines, as Cursor recommends, and commit the file so the whole team gets it. Typing /create-rule in Agent generates a rule file with frontmatter if you would rather start from a description.
The 3:1 in the focus line comes from 1.4.11 Non-text Contrast: W3C’s Understanding document says a focus indicator you style yourself needs 3:1 contrast with the colours next to it. 2.4.7 Focus Visible asks that focus is visible at all. The rest of your design system can be written in the same form, a value and a check per rule: see writing design rules an agent can test.
If you prefer plain Markdown, Cursor also reads AGENTS.md: one in the project root is picked up automatically, and one in a subdirectory applies when Agent works with files in that directory or below it. Cursor reads CLAUDE.md files the same way, and says a CLAUDE.md is applied to every conversation. A team that also uses Claude Code can keep one set of rules: see our Claude Code setup. The old .cursorrules file in the project root is legacy, and Cursor says it will be deprecated.
Give Agent a check it can run
A rule Agent can test is worth more than one it has to remember. This spec runs axe-core’s WCAG A and AA rules on a page as it loads, runs them again with an FAQ answer open, and checks that Tab reaches an FAQ question and Enter opens it:
// tests/a11y.spec.tsimport { test, expect, type Page } from '@playwright/test';import AxeBuilder from '@axe-core/playwright';const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];const scan = (page: Page) => new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();test('/pricing as loaded', async ({ page }) => { await page.goto('/pricing'); expect((await scan(page)).violations).toEqual([]);});test('/pricing with an FAQ answer open', async ({ page }) => { await page.goto('/pricing'); await page.getByRole('button', { name: 'Can I change my plan later?' }).click(); expect((await scan(page)).violations).toEqual([]);});test('/pricing FAQ is reached with Tab and opens with Enter', async ({ page }) => { await page.goto('/pricing'); const question = page.getByRole('button', { name: 'Can I change my plan later?' }); // Press Tab until the question has focus, at most 50 times for (let i = 0; i < 50 && !(await question.evaluate((el) => el === document.activeElement)); i++) { await page.keyboard.press('Tab'); } await expect(question).toBeFocused(); await page.keyboard.press('Enter'); await expect(question).toHaveAttribute('aria-expanded', 'true');});Set baseURL (and a webServer, if Playwright should start your dev server) in playwright.config.ts, and add "test:a11y": "playwright test tests/a11y.spec.ts" to your package.json scripts. The wcag22aa tag matters: axe-core’s target-size rule, its only check for a criterion new in WCAG 2.2, runs only when you ask for it. The second test scans a state the page does not load in, and the third tests behaviour, which no axe-core rule does.
Let Agent run it without asking. Unless your Run Mode allows it, Agent asks before it runs a terminal command; in the default Auto-review mode, a command the review blocks can lead Agent to try another approach instead. Your Run Mode, under Settings > Agents > Approvals & Execution, decides what runs without approval; in the Auto-review and Allowlist modes, allowlisted commands run straight away. To share the allowlist with your team, commit .cursor/permissions.json, unless your team admin controls Run Modes:
{ // Replaces the terminal allowlist in Cursor Settings while this file defines it "terminalAllowlist": ["npm run test:a11y"]}Entries match by prefix, so this also allows npm run test:a11y -- -g /pricing. Note the comment: once a permissions.json file defines terminalAllowlist, the allowlist in Cursor Settings no longer applies, so copy across anything else you rely on.
Send failures back to Agent. Cursor’s hooks run scripts at fixed points of an Agent session. A stop hook runs when the agent loop ends, and if it returns a followup_message, Cursor submits it as your next message. This one runs the check and, when it fails, hands Agent the failures:
{ "version": 1, "hooks": { "stop": [ { "command": "node .cursor/hooks/a11y-check.mjs", "timeout": 300, "loop_limit": 2 } ] }}// .cursor/hooks/a11y-check.mjs: when Agent finishes a turn, run the// accessibility check; if it fails, send the failures back as the next message.import { spawnSync } from 'node:child_process';import { readFileSync } from 'node:fs';const input = JSON.parse(readFileSync(0, 'utf8'));let response = {};if (input.status === 'completed') { const run = spawnSync('npm', ['run', '--silent', 'test:a11y'], { encoding: 'utf8', shell: process.platform === 'win32', }); if (run.error) { // npm did not start: the hook failed to run, which is not a failed check process.stderr.write('a11y-check hook could not run npm: ' + run.error.message + '\n'); } else if (run.status !== 0) { const tail = `${run.stdout}${run.stderr}`.trim().split('\n').slice(-40).join('\n'); response = { followup_message: 'npm run test:a11y failed. Fix only what it reports, follow the ' + 'accessibility project rule, then run it again.\n\n' + tail, }; }}process.stdout.write(JSON.stringify(response) + '\n');The first block goes in .cursor/hooks.json. Project hooks run only in a trusted workspace. They run from the project root, and timeout is in seconds. If npm itself cannot start, the script writes that to the hook’s error output instead of sending Agent back. loop_limit caps how many times the hook can send Agent back (the default is 5), so a fix Agent cannot make ends with you, not in a loop. The hook runs after every Agent turn, so the check’s run time is added to each one; keep it on the branches where you fix accessibility, or keep the spec short.
Know what the check proves. axe-core decides failures a rule can establish from the rendered page: missing names, low contrast, invalid ARIA. A passing run means no rule found a failure in the states the spec reached. It cannot tell whether a label makes sense or the focus order follows the task. We keep a table of which criteria an automated rule can check and what a person still has to. For lint and component tests alongside this spec, and a CI job that runs them all, see our guide to catching accessibility issues while you build.
Hand over one finding at a time
One finding per chat keeps each diff small enough to review. Instead of retyping the instructions each time, save them as a rule with no globs and no description, which makes it an Apply Manually rule. Save this as .cursor/rules/a11y-fix.mdc:
---alwaysApply: false---# Fix one accessibility findingThe finding is in the message that mentions this rule. It names a rule or aperson's finding, a WCAG criterion, a page, a screen size, an element and theevidence.1. Find the component that renders the element. If it is outside src/components or src/pages, stop and tell me where it is.2. Make the smallest change that removes the failure, following the accessibility rule.3. Run `npm run test:a11y -- -g <page path>` and paste the summary.4. List every file you changed, and anything the check cannot confirm.Do not change copy, layout or unrelated code.Then mention it with the finding pasted in:
@a11y-fix axe-core target-size (WCAG 2.5.8), /account at 390px wide,element: .toolbar button.icon-edit, evidence: target is 16 by 16 CSS pixelsThe spec needs a test for each page you fix. With no matching test, -g finds nothing and Playwright fails.
For a finding in a shared component, start in Plan Mode: press Shift+Tab in the chat input to rotate to it. Agent researches the code and writes a plan you can edit before it writes any code, so you can check the scope of the change first.
Three fixes, before and after
We look separately at why AI-generated components often miss keyboard behaviour.
An FAQ accordion built from clickable divs
The finding comes from a keyboard pass, not a rule: on /pricing, the FAQ questions open with a mouse but cannot be reached with Tab. That fails 2.1.1 Keyboard, and the questions have no role or state for a screen reader to announce (4.1.2 Name, Role, Value).
<div className="faq-item"> <div className="faq-question" onClick={() => toggle(i)}> {item.q} </div> {open === i && <div className="faq-answer">{item.a}</div>}</div><div className="faq-item"> <h3 className="faq-heading"> <button type="button" className="faq-question" aria-expanded={open === i} aria-controls={`faq-answer-${i}`} onClick={() => toggle(i)} > {item.q} </button> </h3> <div className="faq-answer" id={`faq-answer-${i}`} hidden={open !== i}> {item.a} </div></div>The button gives keyboard access and Enter and Space for free, and aria-expanded tells screen reader users whether the answer is open. This is the accordion structure in W3C’s ARIA Authoring Practices Guide: the button is the only element inside the heading, and aria-controls names the panel. Rendering the answer with hidden, instead of only when open, keeps the element that aria-controls points at in the page. Use the heading level that fits the page’s outline, and check that Agent moved the old div’s styles to the button and reset its default border and background.
Can automated tools detect this? Not with axe-core: a div with a click handler has no role for a rule to check. The keyboard test in the spec does: getByRole('button') finds nothing on the div version, so it fails until the fix lands.
Icon buttons smaller than 24 pixels
The finding: axe-core’s target-size rule, 2.5.8 Target Size (Minimum), on the account page’s toolbar. The edit and delete buttons are 16 by 16 CSS pixels and 4 pixels apart.
.toolbar button { width: 16px; height: 16px; padding: 0; border: 0; background: none;}.toolbar { display: flex; gap: 4px; }.toolbar button { display: inline-grid; place-items: center; min-width: 24px; min-height: 24px; padding: 4px; border: 0; background: none;}.toolbar { display: flex; gap: 4px; }2.5.8 asks for targets of at least 24 by 24 CSS pixels, with exceptions. The one that matters here is spacing: an undersized target passes if a 24-pixel circle centred on it does not overlap another target or another small target’s circle. At 16 pixels and 4 apart, these fail. Growing the target keeps the icon the same size. The toolbar gets wider, so in a dense layout a person signs off on the change.
Can automated tools detect this? Yes, if you ask: axe-core’s target-size is off by default and runs only when you name the rule or request a tag it carries, such as wcag22aa or wcag258, which is why the spec includes wcag22aa. Our audits also run our own rule for 2.5.8, which applies the W3C circle test at each screen size we test and flags this pair.
Focus hidden under a sticky header
The finding comes from a keyboard pass: on long pages, when you Shift+Tab back up, a focused link can sit under the 72-pixel sticky header, out of sight. That fails 2.4.11 Focus Not Obscured (Minimum). W3C’s Understanding page names the usual causes: “Typical types of content that can overlap focused items are sticky footers, sticky headers, and non-modal dialogs.”
.site-header { position: sticky; top: 0; height: 72px; z-index: 10;}:root { --header-height: 72px; }.site-header { position: sticky; top: 0; height: var(--header-height); z-index: 10;}html { scroll-padding-top: calc(var(--header-height) + 1rem);}scroll-padding-top shrinks the area the browser treats as visible when it scrolls something into view, so a focused element under the header counts as out of view and is scrolled below it. W3C lists the property as a sufficient technique for 2.4.11 (C43). We tested this case in Chromium: without the padding, a link focused just under the header stayed hidden, because the browser treated it as already in view; with it, the page scrolled the link clear. The custom property keeps the padding in step if the header’s height changes.
Can automated tools detect this? axe-core has no rule for it. Tab and Shift+Tab through a long page with the header in view. Our audits include our own rule for 2.4.11, which checks each control as forward Tab reaches it. From each control we also press Shift+Tab once and check the control before it again, but only if the page has moved it since Tab reached it, and only for a fixed or sticky cover such as a header. We switch CSS smooth scrolling to instant while we press Tab and Shift+Tab. A control hidden only after several Shift+Tab presses in a row, or moved by smooth scrolling that a script explicitly requests, still needs the keyboard pass.
Review the diff before you accept it
Agent applies its edits as it works, and the diff view shows them as they happen; reject anything you do not want. On a Mac, Cmd+Return accepts all suggested changes and Cmd+Shift+Backspace rejects them all. If Agent takes a wrong turn, Restore Checkpoint on an earlier message reverts the files to that point; checkpoints are stored locally, separate from Git, so commit what you keep.
Then read the diff for the six shortcuts to look for in an agent’s accessibility fix: labels that exist only as a placeholder or an aria-label, roles with no keyboard support, names that no longer contain the visible words, outlines removed, aria-hidden on focusable content, and a contrast fix that changes a brand colour.
For a second pass, switch to Ask mode, which is read-only, and give it the branch diff and the rule:
@Branch Review this diff against @accessibility. List each problem with fileand line. Do not edit anything.@Branch attaches the diff with your main branch, and @-mentioning a rule applies it to that chat.
On pull requests, Cursor’s Bugbot does not apply your project rules. It reads .cursor/BUGBOT.md files, including any .md or .mdc file a BUGBOT.md links to. Agent Review, which you can run on local changes with /agent-review, also reads BUGBOT.md files. Put the review points there:
# Accessibility reviewFlag these in any change to UI code:- ARIA roles or attributes where a native element would do- role="tab", "menu" or "option" without arrow-key handling- click handlers on elements that are not buttons or links- accessible names that do not contain the visible text- outline: none without a visible :focus-visible replacement- aria-hidden on focusable content- new colour values, or edited design tokensThen do the one check no reviewer can do for you: put the mouse away and use the changed control with the keyboard.
Check the page in a browser, then the deployed page
Cursor’s built-in Browser tool lets Agent open your running app, click, type, take screenshots and read the console, and Cursor’s docs suggest prompts for checking contrast, ARIA labels and keyboard navigation. Use it to find leads. What Agent reports from looking at a page is a model’s judgment, not a measurement: keep it apart from what a rule measured, and confirm it yourself.
The dev server has test data. Your users get the deployed page, with real content, real styles and every screen size. After the change reaches a preview or staging environment, point the spec’s baseURL at it and run the same checks on the same pages. Then check the other pages that use the component you changed. If a rule no longer finds the failure, record it as no longer found on what you tested, not as proof the fix works for everyone.
If your audit tool has an MCP server
Some audit tools publish findings through a remote MCP server, so Agent can read them instead of having them pasted in. Cursor reads project servers from .cursor/mcp.json and your own from ~/.cursor/mcp.json:
{ "mcpServers": { "audit-findings": { "url": "https://audit.example.com/mcp" } }}Cursor supports OAuth sign-in for servers that need it, and, depending on your Run Mode, asks for approval before it uses an MCP tool. Wherever the finding comes from, the rest of this guide applies unchanged.
What to keep human
Some decisions should not be Agent’s, however good the rules are. A person decides which widget a design needs in the first place (a disclosure or a real menu, tabs or a list of sections), what an editor’s image means and what its alt text should say, which colour replaces a brand colour that fails, and whether the focus order matches how people use the page. A person listens to custom widgets with the screen readers your visitors use. And a person merges: Agent’s passing check is evidence for that decision, not the decision.
Verification checklist
.cursor/rules/accessibility.mdcexists, is committed, and every line in it can be checked.- Each task names one finding: rule or person, criterion, page, screen size, element, evidence.
- Agent ran
npm run test:a11yand pasted the result, and the spec covers the states the fix touches. - Every ARIA attribute in the diff has a reason a native element could not provide.
- No click handler on a non-interactive element; no
outline: nonewithout a visible replacement; noaria-hiddenon focusable content. - Colours and tokens changed only with a designer’s decision.
- You used the changed control with the keyboard.
- The same check passed on the preview deployment, at the same screen sizes, and on the other pages that share the component.
- The commit names the finding, so the next audit can be matched to it.
Frequently asked questions
Where do Cursor rules for accessibility go?
In your project’s .cursor/rules folder, as an .mdc file with frontmatter that sets when it applies. A plain .md file in that folder is ignored. If you prefer plain Markdown, Cursor also reads AGENTS.md. The old .cursorrules file is legacy.
Can Cursor check accessibility on its own?
Agent can run a check you give it, such as an axe-core test in Playwright, and its Browser tool can look at the running page. A passing check shows that no rule found a failure in the states it tested. Keyboard behaviour, focus order, wording and what a screen reader announces still need a person.
Should the accessibility rule apply to every chat?
Usually not. Scope it with globs to your component, page and style files, so it loads when Agent works on UI code and stays out of unrelated tasks. Use Always Apply only in a UI-only project.