Accessibility
AI-generated code and accessibility: where the gaps come from
AI coding tools often add labels and alt attributes but miss keyboard, focus and ARIA behaviour. What studies show, and how to catch it.
AI coding tools often get the visible basics of accessibility right: labels, alt attributes, button text. But their code is not accessible by default. In the studies so far, the gaps cluster in ARIA, semantic structure and content a checker cannot judge. In the components we generated for this article, they clustered where accessibility is behaviour rather than markup: keyboard support, focus, states that only appear after an interaction, and ARIA roles that promise behaviour the code does not deliver.
None of the studies we found shows that AI-generated code is less accessible than human-written code in general; one found the reverse on basics.
Most of these gaps pass an automated check. Closing them takes prompts and rules that name the behaviour, tests that exercise each state, and a person with a keyboard.
This article is the evidence behind the audit-to-fix loop for coding agents.
What the research shows
The research is young, mostly small, and mostly measured with automated checkers, so each row says what was tested.
A 2026 systematic review of 38 peer-reviewed studies of language models in web accessibility (Aljedaani and Mollik, accepted at W4A 2026), which covers uses such as content generation, issue detection and remediation, not only code generation, adds a caution about the whole field: “The reviewed approaches predominantly rely on general-purpose LLMs and prompt-based interactions, while evaluation practices vary widely and often lack direct involvement of users with disabilities.”
What the evidence does not show
- That AI code is worse than human code. The one comparison of model output with the human-written original, a preprint, points the other way on basics, and CodeA11y found no significant difference with and without Copilot.
- A rate you can apply to your site. Samples are small, prompts differ, and most studies tested models that have since been replaced.
- What disabled people experience. Most findings come from automated checkers, and only one study above says it tested with a screen reader.
What the studies agree on: accessibility does not come by default, it has to be asked for, and even then someone has to check it.
Where the gaps come from
The web the models learned from
The web is not accessible either. The 2026 WebAIM Million, which runs the WAVE checker on the top million home pages, detected WCAG failures on 95.9% of them. (WebAIM’s conclusion suggests AI-assisted coding is among the likely reasons the figures got worse, but it measured nothing about how pages were built.) It found ARIA, not counting landmark roles, on 82.7% of home pages, and those pages had more detected errors on average (59.1) than pages without it (42). WebAIM adds: “This does not necessarily mean that ARIA introduced these errors (these pages were also more complex), but pages typically had significantly more errors when more ARIA was present.” On menus it is specific: “5.7% of home pages had an ARIA menu (role="menu"), but 22% of those ARIA menus introduced accessibility barriers due to the lack of necessary ARIA menu markup and interactions.” The A11yn authors draw the consequence: “LLMs trained on such web-scale data reproduce these defects in generated UIs.” That is their explanation, not something they measured, but it fits what the generated code looks like.
Prompts describe how it looks
People ask for a product card that looks “modern and clean”, or an “iOS-style toggle switch”. In the CodeA11y study, “none of the participants, including the two who were familiar with web accessibility, prompted with accessibility in mind.” Then they judge the result by looking at it. A missing keyboard handler looks exactly like a working one.
Behaviour and state do not show in a preview
A screenshot shows one state. Whether arrow keys move between tabs, whether focus stays in an open dialog, and what an error message looks like after a failed submit are all invisible until someone interacts with the page.
ARIA is a promise
W3C’s ARIA Authoring Practices Guide explains that <div role="button"> “Is a promise that the author of that <div> has also incorporated JavaScript that provides the keyboard interactions expected for a button.” Generated code often makes the promise, because the role is one attribute, and skips the JavaScript.
Stand-in values and judgment calls
The CodeA11y authors describe what happened to Copilot’s reminders: “Since our participants had limited awareness about these accessibility features, they would often ignore such suggestions by blindly accepting alt = "" // Add your text here or manually deleting the <label> tag.” Calò and colleagues describe the matching gap in checkers: content that is present but meaningless, such as alt="image", passes automated checks.
Checkers pass much of it
CodeA11y reports: “Some errors, such as providing blank alt-texts for informative images, were not even flagged by automated accessibility checkers because they interpret the image as decorative and consider this deliberate.” In our own examples below, axe-core reported nothing for three of the five gaps. A checker decides only what a rule can establish: see what automated rules can check, criterion by criterion.
Five gaps we reproduced
The models also got a lot right. Both toggle switches used a real control with a label and a visible focus outline. Every modal named its close button. All six forms tied each error message to its field. The gaps were the kind a visual check misses.
Tabs you cannot reach with the keyboard
Prompt: “Write a React tabs component with three tabs (Overview, Specs, Reviews) that switches the content panel below when a tab is clicked.”
In 3 of 6 generations, the tabs used a roving tab index, with only the active tab in the Tab order, but no arrow-key handler:
<button role="tab" id={`tab-${tab.id}`} aria-selected={activeTab === tab.id} aria-controls={`panel-${tab.id}`} tabIndex={activeTab === tab.id ? 0 : -1} onClick={() => setActiveTab(tab.id)}>With a keyboard you can reach only the selected tab, Overview at first, which fails 2.1.1 Keyboard. It is half of W3C’s tabs pattern. In the APG’s version, Tab moves to the active tab and then out of the tab list, and the arrow keys move between tabs: “Right Arrow: Moves focus to the next tab. If focus is on the last tab element, moves focus to the first tab.” The generated code kept the first half. The other three generations left every tab in the Tab order, which works.
Can automated tools detect this? axe-core 4.13.0 reported no violation. It also skipped the aria-controls values that point at panels not in the page: axe-core does not check aria-controls on a tab marked aria-selected="false".
The fix adds the arrow keys and renders every panel, so each aria-controls has a target:
function onKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) { const last = tabs.length - 1; const next = e.key === "ArrowRight" ? (index === last ? 0 : index + 1) : e.key === "ArrowLeft" ? (index === 0 ? last : index - 1) : e.key === "Home" ? 0 : e.key === "End" ? last : null; if (next === null) return; e.preventDefault(); setActiveTab(tabs[next].id); tabRefs.current[next]?.focus();}// Each tab gets ref={(el) => { tabRefs.current[index] = el; }} and// onKeyDown={(e) => onKeyDown(e, index)}. Every panel is rendered:{tabs.map((tab) => ( <div key={tab.id} role="tabpanel" id={`panel-${tab.id}`} aria-labelledby={`tab-${tab.id}`} tabIndex={0} hidden={activeTab !== tab.id}> {tab.content} </div>))}A site menu marked up as an app menu
Prompt: a site header with a “Products” dropdown of four links.
All three Haiku generations marked the links up as an ARIA menu (role="menu", role="menuitem", aria-haspopup="true") with no arrow-key support, and replaced the focus outline with a pale background:
.site-header__dropdown-link:hover,.site-header__dropdown-link:focus-visible { background: #f3f4f6; outline: none;}The menu role is for application menus, which are operated with arrow keys: in W3C’s menu pattern, “Tab and Shift + Tab do not move focus among the items in the menu.” W3C’s disclosure navigation example avoids the role, because “Typical site navigation does not need all the keyboard interactions specified by the menu and menubar pattern.” The focus background is 1.10:1 against white. W3C’s guidance for 1.4.11 Non-text Contrast says that, together with 2.4.7 Focus Visible, a focus indicator needs 3:1 against the adjacent background, so this one falls far short. All three Sonnet generations used a button with aria-expanded and a visible outline instead, though one also set aria-haspopup="true", which announces a menu.
Can automated tools detect this? No. axe-core passed the menu: the roles are valid and correctly nested, and no rule checks arrow keys or how visible focus is.
The fix is a disclosure: drop the three menu attributes, keep aria-expanded, point aria-controls at a list that stays in the page, and restore an outline:
<button ref={buttonRef} type="button" aria-expanded={isOpen} aria-controls="products-links" onClick={() => setIsOpen((open) => !open)}> Products <span aria-hidden="true">▾</span></button><ul id="products-links" hidden={!isOpen}> {productLinks.map((link) => ( <li key={link.href}><a href={link.href}>{link.label}</a></li> ))}</ul>.site-header__trigger:focus-visible,.site-header__dropdown-link:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px;}Keep the generated Escape handler, and add buttonRef.current?.focus() to it, so focus returns to the button when the list closes.
A modal that lets focus walk out
Prompt: a “Sign up” button that opens a modal with an email field, a “Subscribe” button and a close button.
All six generations built the modal from a div with role="dialog" and aria-modal="true". None kept Tab inside it, and none returned focus to “Sign up” on close. Pressing Tab moves into the page behind the overlay, where focus lands on controls the overlay covers. Depending on the layout, that can fail 2.4.3 Focus Order, or 2.4.11 Focus Not Obscured when the overlay hides the focused control completely. W3C’s dialog pattern expects the opposite: Tab “Moves focus to the next tabbable element inside the dialog.” In three of the six, the email field’s only label was its placeholder, which disappears as soon as someone types.
Can automated tools detect this? No. axe-core’s label rule accepts a non-empty placeholder as a name, so the field passed, and no axe-core rule follows focus.
The native dialog element, opened with showModal(), makes the rest of the page inert, closes on Escape and returns focus to the button that opened it:
const dialogRef = useRef<HTMLDialogElement>(null);<button type="button" onClick={() => dialogRef.current?.showModal()}>Sign up</button><dialog ref={dialogRef} aria-labelledby="signup-title"> <button type="button" aria-label="Close" onClick={() => dialogRef.current?.close()}> × </button> <h2 id="signup-title">Sign up</h2> <form onSubmit={handleSubmit}> <label htmlFor="signup-email">Email address</label> <input id="signup-email" type="email" autoComplete="email" required /> <button type="submit">Subscribe</button> </form></dialog>We checked it in Chromium: Tab cycled through the close button, the field and Subscribe without reaching the page behind, Escape closed the dialog, and focus went back to “Sign up”. handleSubmit calls dialogRef.current?.close() when it is done.
An error colour that fails only when the error shows
Prompt: a sign-up form that validates on submit and shows an error under each invalid field.
One Sonnet generation styled its error messages #e53e3e at 0.8rem on white: 4.13:1, below the 4.5:1 that 1.4.3 Contrast (Minimum) asks for at that size. The error text exists only after a failed submit, so a scan of the page as it loads has nothing to measure. (The same form’s white-on-blue submit button, 4.03:1, does fail on load. In four of the six forms the submit button was below 4.5:1, from 3.29:1 to 4.03:1.) axe-core’s color-contrast rule flagged it as soon as we rendered the error state. A darker red, #c53030 (5.47:1), passes; put it in your design tokens rather than the component.
Two of the three Haiku generations left autocomplete off the name, email and password fields. The usual way to meet 1.3.5 Identify Input Purpose is the autocomplete attribute (name, email, new-password). axe-core’s autocomplete-valid rule checks only values that are present, so the form passed.
A brand colour just under the line
Prompt: a “modern and clean” product card. One generation set the category label in 12-pixel uppercase indigo, #6366f1 on white: 4.47:1, just under 4.5:1. axe-core caught it, and the fix is one token, but it is typical of a prompt about looks: a colour chosen for the design that misses by a hair.
How to close the gaps
Put rules like these where your agent reads them at the start of every session. We show how, with testable rules in CLAUDE.md, a check the agent must run and a review step. For Cursor, we give a Cursor rules file to copy.
Test the states, not just the page. A scan of a loaded page sees one state. Drive each component into its other states, scan again, and assert the keyboard behaviour directly:
// Inside Playwright tests, with AxeBuilder from @axe-core/playwright// The sign-up form's error stateawait page.goto('/signup');await page.getByRole('button', { name: 'Sign up' }).click(); // submit it emptyconst results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) .analyze();expect(results.violations).toEqual([]);// The tabs' keyboard behaviourawait page.goto('/product');await page.getByRole('tab', { name: 'Overview' }).focus();await page.keyboard.press('ArrowRight');await expect(page.getByRole('tab', { name: 'Specs' })).toBeFocused();We show how to run checks like these in CI, alongside lint and component tests, in our guide to accessibility checks that run during development.
Review like a person who uses a keyboard. Read the diff for the shortcuts agents take when they fix things, then put the mouse away and use the component. Test custom widgets with the screen readers your visitors use. No checker in this article can tell you what they announce.
Is it accessibility debt?
“Accessibility debt” is a fair name for what accumulates, as long as it is not read as a measurement. The gaps above are old kinds of failure, produced faster: more interface ships in less time, each component with the same small gaps, and a fix nobody re-checks can make things worse.
A 2026 pilot study points that way. Wanscher and colleagues let small open-weight models write CSS repairs that a browser extension injected into live pages, unchecked. Across 100 trials from the five models that produced usable CSS, 24 improved a site and 20 made one measurably worse. That is far from a coding agent editing your source, and no test reached statistical significance, so read it as a direction. Their remedy holds for any generated code: accept a change only when a re-check finds fewer failures and no new kind of failure, and have people check what the automated checks cannot see.
Frequently asked questions
Is AI-generated code less accessible than code written by people?
The research does not show that. One preprint found that two models, regenerating the interface code of ten open-source sites, produced code with a lower rate of violations flagged by automated checkers than the original code on basics such as contrast and alt text, while struggling with ARIA. What the studies agree on is narrower: AI-generated code is not accessible by default.
Does asking for accessibility in the prompt fix it?
It helps, and it is not enough. Studies found that accessibility-focused and follow-up prompts reduced violations, but barriers remained. Name the behaviour you need, such as arrow keys between tabs or focus returning when a dialog closes, and check it.
Will an automated checker catch what the AI got wrong?
Some of it. Checkers catch missing names, low contrast and invalid ARIA in the states they see. In our examples, axe-core passed tabs that a keyboard cannot reach, a menu with the wrong role and a field labelled only by a placeholder. Keyboard behaviour and focus need a person or a test that exercises them.