{"slug": "ai-generated-ui-is-inaccessible-by-default", "title": "AI-Generated UI Is Inaccessible by Default", "summary": "AI code-generation tools like Claude Code, Codex, and Cursor produce React components that are visually correct but inaccessible by default, with failures such as missing landmarks, headings, list structures, and keyboard support. A five-layer enforcement system of prompt constraints—including static analysis, runtime testing, CI integration, and accessible component abstractions—is proposed to enforce semantic correctness, though specialized tools like Vercel's v0 have begun hardcoding accessible primitives.", "body_md": "**A five-layer enforcement system for semantic correctness in LLM-generated React components**\n\nThese days, an AI code-generation tool (e.g., Claude Code, Codex, Cursor) can produce a React sidebar component in 8 seconds. It *looks* correct: smooth hover states, rotating chevrons, harmonious spacing. But take a look at the browser’s accessibility tree in DevTools. Chances are the root side element tree receives: role `generic`\n\n, name `none`\n\n, focusable `false`\n\n. For screen reader users, keyboard navigators, and voice control users, the component effectively doesn’t exist.\n\nThis happens because most LLMs optimize for visual output while generating near-zero semantic information for the layer that assistive technologies actually read. This article explains the architectural reasons and presents a five-layer enforcement system of prompt constraints. These include static analysis, runtime testing, CI integration, and accessible component abstractions that make semantic correctness automatic rather than aspirational. The examples use React and Tailwind because that’s what most AI tools emit, but the accessibility tree doesn’t care about your framework. It cares about the DOM it receives.\n\nA caveat: the landscape is not monolithic. Specialized tools like Vercel’s [v0](https://v0.app/) have begun hardcoding accessible primitives into their generation pipelines v0 outputs [shadcn/ui](https://ui.shadcn.com/) components built on Radix, which means its output inherits Radix’s accessibility contracts by default.\n\nThis is the right architectural approach, and it’s encouraging. But v0 is the exception. The general-purpose tools that most developers use daily, like ChatGPT, Claude, Copilot, and Cursor, still produce the `<div>`\n\nsoup this article dissects. And even v0’s output benefits from the verification layers described here, because no generation pipeline eliminates the need to confirm that what is shipped actually works.\n\nThe Problem, Demonstrated\n\nHere is a navigation sidebar representative of what general-purpose AI code generation tools produce. I’ve tested variations of this prompt across multiple tools; the structural patterns are consistent.\n\nThe browser’s accessibility tree for this component\n\n```\ngroup  text \"Settings\"  group    group      text \"Account\"      image (no accessible name)    group      text \"Profile\"      text \"Security\"\n```\n\nWhat’s broken:\n\n**No landmark.** The outer`<div>`\n\nproduces no navigation role. Screen reader users can’t jump to this section via landmark navigation.**No heading.**“Settings” is a styled`<div>`\n\n, not an`<h2>`\n\n. Navigation by heading will never find it.**No list structure.** The items aren’t in`<ul>`\n\n/`<li>`\n\n. No “list, 2 items” context.**Wrong role on the toggle.** The Account`<div>`\n\nmaps to`generic`\n\n. Not announced as interactive.**Not focusable.**`<div>`\n\nelements aren’t in the tab order. Keyboard users can’t reach the toggle.**No expanded/collapsed state.** No`aria-expanded`\n\n. The chevron rotation is visual-only.**No control relationship.** No`aria-controls`\n\nlinking the trigger to the panel.**No keyboard interaction.** No`onKeyDown`\n\n. Even if focused, Enter and Space do nothing.**Unlabeled icon.** The SVG lacks both`aria-hidden`\n\nand an accessible name.**Fake links.** Profile and Security are`<div>`\n\nelements with click handlers. No`link`\n\nrole, not focusable, can’t be opened in a new tab.\n\nTen distinct failures in twenty-nine lines. The exact count matters less than the density: nearly every element is semantically wrong, and the failures compound. A screen reader user encountering this hears flat, unstructured text with no affordance for interaction.\n\nIn my testing across several tools over the past two months, this pattern was pervasive. `<div onClick>`\n\ninstead of `<button>`\n\nor `<a>`\n\nappeared in the vast majority of interactive components. Missing ARIA state attributes were nearly universal. Keyboard handling was absent from almost every custom control. Landmarks were missing from most layouts. Icons shipped without text alternatives more often than not\n\nGeneral-purpose AI tools are improving some recent model versions generate better semantic HTML than they did six months ago, and some are beginning to incorporate accessibility-aware system prompts. But improvement is inconsistent, and the default output remains inaccessible enough to require systematic enforcement.\n\nWhy This Happens: The Accessibility Tree\n\nWhen the browser receives your HTML and CSS, it builds two primary representations. The **render tree** (derived from the DOM and CSSOM) determines what gets painted to the screen: layout, color, typography, hover states, and transitions. This is the visual layer, and it’s the one AI-generated code handles well.\n\nBut the browser also constructs a separate, parallel structure: the **Accessibility Tree**. This is a filtered, semantically-enriched representation of the DOM, built according to the [WAI-ARIA](https://www.w3.org/TR/wai-aria-1.2/), [HTML-AAM](https://www.w3.org/TR/html-aam-1.0/), and [Core AAM](https://www.w3.org/TR/core-aam-1.2/) specifications. When a screen reader traverses a page, it reads this tree, not the DOM, not the render tree. Each node has four properties:\n\n**Role**: What is this thing? (`button`\n\n,`link`\n\n,`navigation`\n\n,`tab`\n\n)**Name**: What is it called? (computed from text content,`aria-label`\n\n,`alt`\n\n)**State**: What condition is it in? (`expanded`\n\n,`selected`\n\n,`checked`\n\n,`disabled`\n\n)**Value**: What data does it hold? (input value, progress value)\n\nThe render tree and the accessibility tree are built from the same DOM, but they serve different consumers and extract different information. The render tree cares about `cursor-pointer`\n\nand `hover:bg-gray-800`\n\n. The accessibility tree cares about `<button>`\n\n, `aria-expanded`\n\n, and `aria-controls`\n\n. CSS can make a `<div>`\n\n*look* like a button. Only HTML semantics can make it *be* one.\n\n```\n<button>Account</button>\n<!--\n  role: button | name: \"Account\" | focusable: true | activation: Enter, Space\n-->\n<div onClick={handleClick}>Account</div>\n<!--\n  ​​role: generic | name: none | focusable: false | activation: none\n-->\n```\n\nSame pixels. One is a door. The other is a painting of a door.\n\nWhy AI Gets This Wrong\n\nThe exact mechanisms are difficult to isolate empirically, but several plausible factors explain the consistent bias:\n\n**Training data**: Most React code on GitHub uses`<div>`\n\nelements with CSS classes. Semantic HTML and ARIA are underrepresented, which likely means they’re underrepresented in training corpora.**Feedback loops**: When developers and RLHF evaluators assess AI output, they almost certainly evaluate visually. The feedback signal reinforces visual fidelity without penalizing semantic failures.**Token economics**:`<div onClick={fn}>`\n\nis fewer tokens than`<button type=\"button\" aria-expanded={isOpen} aria-controls=\"panel-id\">`\n\n. Absent specific constraints, the model has no incentive to spend extra tokens.**No AOM model**: The model has no representation of the accessibility tree. It models what code*looks like*, not what code*means*to assistive technologies.\n\nThese are informed hypotheses. But the pattern they predict high visual fidelity, near-zero semantic fidelity, matches what I observe consistently.\n\nUnderstanding this makes the fix clear: you need to enforce semantic correctness at every stage where it could be lost.\n\nLayer 1: Prompt Constraints\n\nAn unconstrained prompt produces unconstrained output. The model converges on its statistical default `<div>`\n\nsoup. A constrained prompt narrows the output space to semantically valid components.\n\n**This prompt should not be typed manually each time.** Bake it into your workspace context: Cursor reads a `.cursorrules`\n\nfile from your project root. GitHub Copilot supports `.github/copilot-instructions.md`\n\n. Other tools have similar mechanisms. The prompt below belongs in one of those files, applied automatically to every generation. If your tool doesn’t support persistent context, you have a stronger argument for investing in Layers 2 5.\n\n```\n# Component Generation RulesYou are generating a React component. Follow these rules strictly.## HTML Semantics- Use <button> for actions. Never <div onClick> or <span onClick>.- Use <a href=\"...\"> for navigation. Never <span onClick={navigate}>.- Use <nav>, <main>, <aside>, <header>, <footer> for landmarks.- Use <h1>-<h6> in correct hierarchical order. Do not skip levels.- Use <ul>/<ol> with <li> for lists.- Use <table>, <thead>, <tbody>, <th>, <td> for tabular data.- Use <form>, <fieldset>, <legend>, <label> for forms.- Use <dialog> for modal dialogs with its showModal() API.- Use <details>/<summary> for simple disclosures when appropriate.## Accessibility- Every interactive element must have an accessible name   (visible text, aria-label, or aria-labelledby).- Every form input must have an associated <label> or aria-label.- Icon-only buttons: aria-label on button, aria-hidden on icon.- Decorative images: alt=\"\" or aria-hidden=\"true\".- Dynamic state: use aria-expanded, aria-selected, aria-checked,   aria-current, aria-disabled as appropriate.- Use aria-live=\"polite\" for status messages.- Use aria-describedby for help text and error messages.## Keyboard Interaction- All interactive elements must be keyboard accessible.- Use focus-visible styles. Never remove outlines without replacement.- Composite widgets: arrow keys per WAI-ARIA Authoring Practices.- Modals must trap focus and restore it on close.- Escape must close overlays.## Motion- Respect prefers-reduced-motion. Use motion-safe: or   motion-reduce: Tailwind variants on transitions involving   spatial movement (transforms, position changes, scaling).   Simple color transitions on hover/focus are acceptable   without motion guards.## Library Preferences- For complex patterns (tabs, combobox, dialog, listbox, menu),   use Headless UI, Radix UI, or React Aria instead of building   from scratch.- Use Tailwind CSS for styling.- Include focus-visible ring styles on all interactive elements.## Testing- Query elements using getByRole with accessible name,   not getByTestId.\n```\n\n[Here are some other ideas](https://ericwbailey.website/published/heres-how-to-instruct-a-llm-to-reference-the-aria-authoring-practices-guide/) for prompting from Eric Bailey.\n\nHere is the sidebar regenerated with these constraints:\n\nThe accessibility tree:\n\n```\nnavigation \"Settings\"     heading \"Settings\" (level 2)    list (2 items)     listitem        button \"Account\" (expanded)        region \"Account\"            list (2 items)              listitem                  link \"Profile\"               listitem                  link \"Security\"     listitem        button \"Preferences\" (collapsed)\n```\n\nEvery element has a role, a name, and a state. None of this is React-specific; the same structure in plain HTML produces the same tree:\n\nSame `<button>`\n\n, same `aria-expanded`\n\n, same `aria-controls`\n\n, same accessibility tree. The remaining examples use React because that’s where AI generation is most prevalent, but the principles have equivalents in every ecosystem (`eslint-plugin-vuejs-accessibility`\n\nfor Vue, SvelteKit’s built-in a11y warnings, and axe-core works against any rendered DOM regardless of origin).\n\nWhen the Model Ignores Your Constraints\n\nThis happens regularly. Three strategies:\n\n**Targeted follow-up**: Don’t regenerate from scratch. Prompt with a specific correction:*“The Account toggle is a div with onClick. Replace it with a button and add aria-expanded and aria-controls.”***Audit prompt**: After generation:*“Audit this component for WCAG 2.1 AA violations and fix all issues. Check for: interactive divs that should be buttons, missing aria-expanded/aria-controls, missing keyboard support, missing focus-visible styles.”*Models review code more reliably than they generate correct code from scratch.**Manual checklist**: Before committing, are there interactive elements`<button>`\n\nor`<a>`\n\n? Do toggles have`aria-expanded`\n\n? Can you Tab to and activate every control? Do landmarks and headings exist? Do icons have`aria-hidden`\n\n? Two minutes.\n\nWhen Layer 1 is weak or absent, inline Copilot suggestions and tab completions, where there’s no prompt to constrain Layers 2 through 5, become your primary defense. The system is designed for the reality that any individual layer might fail.\n\nLayer 2: Static Analysis\n\nYou can be checking your code directly with tools. A great option for the code we’re working with so far is [ESLint](https://eslint.org/) with the [eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y) plugin.\n\n```\nnpm install --save-dev eslint-plugin-jsx-a11y\n```\n\nHere’s an example configuration that errors on issues we know are preventable through better code.\n\n```\nexport default [\n  {\n    plugins: { 'jsx-a11y': jsxA11y },\n    rules: {\n      'jsx-a11y/click-events-have-key-events': 'error',\n      'jsx-a11y/no-static-element-interactions': 'error',\n      'jsx-a11y/no-noninteractive-element-interactions': 'error',\n      'jsx-a11y/no-noninteractive-element-to-interactive-role': 'error',\n      'jsx-a11y/alt-text': 'error',\n      'jsx-a11y/aria-props': 'error',\n      'jsx-a11y/aria-proptypes': 'error',\n      'jsx-a11y/anchor-is-valid': 'error',\n      'jsx-a11y/interactive-supports-focus': 'error',\n      'jsx-a11y/label-has-associated-control': 'error',\n      'jsx-a11y/role-has-required-aria-props': 'error',\n      'jsx-a11y/role-supports-aria-props': 'error',\n    },\n  },\n];\n```\n\nNow when the AI generates poor code like `<div onClick>`\n\n, we’ll get an error message when calling this tool like:\n\n```\nerror Visible, non-interactive elements with click handlers must have at least one keyboard listener jsx-a11y/click-events-have-key-events\n```\n\nWith pre-commit hooks and CI enforcement, the code cannot ship.\n\nStatic analysis is fast and cheap, but it can only evaluate JSX structure; it can’t assess runtime behavior, dynamic state, or the actual accessibility tree.\n\nLayer 3: Runtime Testing\n\nWe can add another layer on top of static analysis: actual browser testing. A tool like [Playwright](https://playwright.dev/) can help here along with a plugin specifically for accessibility testing, [@axe-core/playwright](https://www.npmjs.com/package/@axe-core/playwright).\n\n```\nnpm install --save-dev jest-axe @axe-core/playwright\n```\n\nHere’s an example test that mounts the actual components and tests things in a browser.\n\n``` python\nimport { render, screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { axe, toHaveNoViolations } from 'jest-axe';\nimport { SettingsSidebar } from './SettingsSidebar';\n\nexpect.extend(toHaveNoViolations);\n\ndescribe('SettingsSidebar', () => {\n  it('has no axe violations in collapsed state', async () => {\n    const { container } = render(<SettingsSidebar />);\n    expect(await axe(container)).toHaveNoViolations();\n  });\n\n  it('has no axe violations in expanded state', async () => {\n    const { container } = render(<SettingsSidebar />);\n    await userEvent.setup().click(\n      screen.getByRole('button', { name: /account/i })\n    );\n\n    // axe evaluates the DOM at a point in time. Scanning during\n    // a React re-render or CSS transition can produce results \n    // against intermediate DOM states. Wait for it to settle.\n    await waitFor(async () => {\n      expect(await axe(container)).toHaveNoViolations();\n    });\n  });\n\n  it('supports full keyboard navigation', async () => {\n    render(<SettingsSidebar />);\n    const user = userEvent.setup();\n\n    await user.tab();\n    expect(\n      screen.getByRole('button', { name: /account/i })\n    ).toHaveFocus();\n\n    await user.keyboard('{Enter}');\n    expect(\n      screen.getByRole('button', { name: /account/i })\n    ).toHaveAttribute('aria-expanded', 'true');\n\n    await user.tab();\n    expect(\n      screen.getByRole('link', { name: /profile/i })\n    ).toHaveFocus();\n  });\n\n  it('exposes correct roles and states', () => {\n    render(<SettingsSidebar />);\n\n    expect(\n      screen.getByRole('navigation', { name: /settings/i })\n    ).toBeInTheDocument();\n\n    expect(\n      screen.getByRole('button', { name: /account/i })\n    ).toHaveAttribute('aria-expanded', 'false');\n  });\n});\n```\n\nThese tests query by **role** and **accessible name**. This is not a stylistic choice. If `getByRole('button', { name: /account/i })`\n\nfails, a screen reader can’t find that element either. The testing Library’s query API is itself an accessibility assertion.\n\nWhat axe-core Catches and What It Doesn’t\n\naxe-core reliably detects missing ARIA attributes, invalid ARIA usage, missing form labels, missing text alternatives, many color contrast failures, incorrect role usage, and duplicate IDs. These are structural problems with deterministic answers, and axe is excellent at them.\n\nIt cannot assess whether your labels are *meaningful;* it checks that an `aria-label`\n\nexists, not that “click here” is useful. It cannot evaluate whether focus moves to the *right place* when a modal opens. It cannot catch mismatches between reading order and visual order, whether a live region fires at the right moment, voice control usability, or cognitive load.\n\naxe-core confirms that the accessibility scaffolding is in place. It cannot confirm that the building is habitable. Manual testing with screen readers, VoiceOver, NVDA, and JAWS, remains necessary.\n\nLayer 4: CI Integration\n\nContinuous Integration (CI) is a powerful place to test accessibility issues as it has real power. You can set it up such that Pull Requests (PRs) on the codebase cannot be merged unless the tests pass (like the tests we talked about above).\n\n[GitHub Actions](https://docs.github.com/en/actions) isn’t the only option, but it’s very popular because GitHub itself is. Here is an example [workflow](https://docs.github.com/en/actions/concepts/workflows-and-actions/workflows) that runs our static-analysis linting and also includes a new browser test that loads the actual page and clicks things.\n\n```\nname: Accessibility Checks\non: [push, pull_request]\n\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with: { node-version: '20' }\n      - run: npm ci\n      - name: ESLint a11y rules\n        run: npx eslint 'src/**/*.{ts,tsx}' --max-warnings 0\n\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with: { node-version: '20' }\n      - run: npm ci\n      - name: Component accessibility tests\n        run: npx jest --testPathPattern='\\.test\\.'\n\n  e2e:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with: { node-version: '20' }\n      - run: npm ci\n      - name: Playwright axe audit\n        run: npx playwright test --grep @a11y\n```\n\nAnd here’s an End-to-End test example (i.e. see how it visits actual URLs).\n\n``` python\nimport { test, expect } from '@playwright/test';\nimport AxeBuilder from '@axe-core/playwright';\n\ntest('settings page passes axe audit @a11y', async ({ page }) => {\n  await page.goto('/settings');\n  await page.waitForLoadState('networkidle');\n\n  const results = await new AxeBuilder({ page })\n    .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])\n    .analyze();\n  expect(results.violations).toEqual([]);\n});\n\ntest('sidebar keyboard navigation @a11y', async ({ page }) => {\n  await page.goto('/settings');\n  await page.keyboard.press('Tab');\n\n  // Playwright locators are lazy-evaluated — :focus resolves to\n  // whatever element has focus at assertion time, not capture time.\n  const focused = page.locator(':focus');\n  await expect(focused).toHaveRole('button');\n  await expect(focused).toHaveAttribute('aria-expanded', 'false');\n\n  await page.keyboard.press('Space');\n  await expect(focused).toHaveAttribute('aria-expanded', 'true');\n\n  await page.keyboard.press('Tab');\n  await expect(page.locator(':focus')).toHaveRole('link');\n});\n```\n\nLayer 5: Accessible Component Abstractions\n\nThe deepest solution is architectural. Instead of relying on every prompt to produce correct primitives, use libraries that encode accessibility into their API contracts.\n\n[Headless UI](https://headlessui.com/)provides roughly ten components designed for Tailwind Disclosure, Dialog, Listbox, Menu, and Tabs. Smallest API surface, gentlest learning curve, fastest integration. Its limitation is scope: if you need a pattern it doesn’t cover, you’re on your own.[Radix UI](https://www.radix-ui.com/)covers roughly thirty unstyled primitives with a composable component API the pragmatic middle ground for most projects.[React Aria](https://react-spectrum.adobe.com/react-aria/)from Adobe provides over forty hooks covering virtually every ARIA pattern, plus internationalization and virtual scrolling. It’s the right choice for teams building a design system. It’s overkill for adding an accessible dropdown to a CRUD app. The choice between them matters less than the decision to use*one of them*instead of letting AI build interactive primitives from`<div>`\n\nelements.\n\nHere’s an example of using Headless UI (bringing Tailwind for styling).\n\n``` js\nimport { Disclosure } from '@headlessui/react';\n\nfunction SettingsSidebar() {\n  return (\n    <nav aria-label=\"Settings\" className=\"w-64 bg-gray-900 \n                                          text-white h-screen p-4\">\n      <h2 className=\"text-xl font-bold mb-6 px-2\">Settings</h2>\n      \n      <ul className=\"space-y-2 list-none\" role=\"list\">\n        {sections.map(section => (\n          <li key={section.id}>\n            <Disclosure>\n              {({ open }) => (\n                <>\n                  <Disclosure.Button\n                    className=\"flex w-full items-center \n                               justify-between px-2 py-2 rounded-lg \n                               text-sm font-medium hover:bg-gray-800 \n                               focus-visible:ring-2 \n                               focus-visible:ring-blue-500\n                               transition-colors\"\n                  >\n                    {section.label}\n                    <ChevronIcon\n                      aria-hidden=\"true\"\n                      className={`w-4 h-4 \n                                 motion-safe:transition-transform\n                                 motion-safe:duration-200 ${\n                                   open ? 'rotate-180' : ''\n                                 }`}\n                    />\n                  </Disclosure.Button>\n\n                  <Disclosure.Panel className=\"ml-4 mt-1 space-y-1\">\n                    <ul className=\"list-none\" role=\"list\">\n                      {section.links.map(link => (\n                        <li key={link.href}>\n                          <a\n                            href={link.href}\n                            className=\"block px-2 py-1.5 text-sm \n                                       text-gray-300 hover:text-white \n                                       hover:bg-gray-800 rounded \n                                       focus-visible:ring-2\n                                       focus-visible:ring-blue-500\n                                       transition-colors\"\n                          >\n                            {link.label}\n                          </a>\n                        </li>\n                      ))}\n                    </ul>\n                  </Disclosure.Panel>\n                </>\n              )}\n            </Disclosure>\n          </li>\n        ))}\n      </ul>\n    </nav>\n  );\n}\n```\n\nBy using these components, the aria-expanded attribute, keyboard activation, and panel association are handled automatically. If we can prompt the AI to use these components and work this way, the AI’s job shrinks to visual composition. The accessibility is structural. There can be advantages to **decoupling the semantic layer from the visual layer.** We let battle-tested libraries own the semantics and let AI own the styling.\n\nCommon AI Failure Patterns\n\nThree additional patterns beyond the sidebar merit attention because they involve more complex accessibility requirements.\n\nThe Invisible Modal\n\n```\n{isOpen && (\n  <div className=\"fixed inset-0 z-50 flex items-center \n                  justify-center bg-black/50\">\n    <div className=\"rounded-xl bg-white p-6 shadow-xl\">\n      <div className=\"text-lg font-bold\">Confirm</div>\n      <div className=\"mt-2\">Are you sure?</div>\n      <div className=\"mt-4 flex gap-2\">\n        <div className=\"cursor-pointer rounded bg-red-500 px-4 py-2 \n                        text-white\" onClick={onConfirm}>Yes</div>\n        <div className=\"cursor-pointer rounded bg-gray-300 px-4 py-2\" \n             onClick={onClose}>No</div>\n      </div>\n    </div>\n  </div>\n)}\n```\n\nIssues:\n\n- No\n`role=\"dialog\"`\n\n- No\n`aria-modal`\n\n- No label\n- No focus trap\n- No Escape handler\n- No focus restoration\n\nThe native `<dialog>`\n\nelement with `showModal()`\n\nprovides focus trapping, Escape to close, `aria-modal`\n\n, and backdrop handling for free. Better:\n\n```\n<dialog\n  ref={dialogRef}\n  className=\"rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50\"\n  aria-labelledby=\"dialog-title\"\n  onClose={onClose}\n>\n  <h2 id=\"dialog-title\" className=\"text-lg font-bold\">Confirm</h2>\n  <p className=\"mt-2\">Are you sure?</p>\n  <div className=\"mt-4 flex gap-2\">\n    <button type=\"button\"\n            className=\"rounded bg-red-500 px-4 py-2 text-white \n                       hover:bg-red-600 focus-visible:ring-2 \n                       focus-visible:ring-red-400\"\n            onClick={onConfirm}>Yes</button>\n    <button type=\"button\"\n            className=\"rounded bg-gray-300 px-4 py-2 \n                       hover:bg-gray-400 focus-visible:ring-2 \n                       focus-visible:ring-gray-500\"\n            onClick={onClose}>No</button>\n  </div>\n</dialog>\n```\n\nOne caveat: native `<dialog>`\n\nhas mostly excellent support in modern browsers, but there are still edge cases, particularly around focus restoration in older WebKit versions and inconsistent aria-modal behavior across screen reader/browser combinations. Test with your target assistive technologies. For maximum cross-browser reliability, use Radix Dialog or Headless UI Dialog to handle these edge cases.\n\nThe Icon-Only Button\n\n```\n<div className=\"cursor-pointer rounded-full p-2 hover:bg-gray-100\"\n     onClick={onClose}>\n  <svg className=\"h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\" \n       stroke=\"currentColor\">\n    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" \n          strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n  </svg>\n</div>\n```\n\nIssues:\n\n- The\n`role`\n\nis`generic`\n\ninstead of`button`\n\n- Entirely empty name – invisible to assistive technology\n- Unfocusable\n\nBetter to use the correct native elements and attributes to help with naming:\n\n```\n<button type=\"button\"\n        aria-label=\"Close dialog\"\n        className=\"rounded-full p-2 hover:bg-gray-100 \n                   focus-visible:ring-2 focus-visible:ring-blue-500\"\n        onClick={onClose}>\n  <svg aria-hidden=\"true\" className=\"h-5 w-5\" viewBox=\"0 0 24 24\" \n       fill=\"none\" stroke=\"currentColor\">\n    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" \n          strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n  </svg>\n</button>\n```\n\nThe Custom Select\n\n```\n<div className=\"relative\">\n  <div className=\"cursor-pointer rounded border px-3 py-2\" \n       onClick={() => setIsOpen(!isOpen)}>\n    {selected || 'Choose an option'}\n  </div>\n  {isOpen && (\n    <div className=\"absolute mt-1 w-full rounded border bg-white \n                    shadow-lg\">\n      {options.map(opt => (\n        <div key={opt} className=\"cursor-pointer px-3 py-2 \n                                  hover:bg-blue-50\"\n             onClick={() => { setSelected(opt); setIsOpen(false); }}>\n          {opt}\n        </div>\n      ))}\n    </div>\n  )}\n</div>\n```\n\nIssues:\n\n- No\n`listbox`\n\nrole - No\n`aria-expanded`\n\nattribute - No\n`aria-selected`\n\nattribute - No\n`aria-activedescendant`\n\nattribute - No keyboard navigation\n\nA correct implementation requires ~150 lines of keyboard management. My suggestion is to use a headless library:\n\n``` js\nimport { Listbox } from '@headlessui/react';\n\n<Listbox value={selected} onChange={setSelected}>\n  <Listbox.Label className=\"block text-sm font-medium text-gray-700\">\n    Choose an option\n  </Listbox.Label>\n  <div className=\"relative mt-1\">\n    <Listbox.Button className=\"relative w-full cursor-pointer rounded \n                               border bg-white py-2 pl-3 pr-10 \n                               text-left focus-visible:ring-2 \n                               focus-visible:ring-blue-500\">\n      {selected || 'Select...'}\n    </Listbox.Button>\n    <Listbox.Options className=\"absolute mt-1 w-full rounded border \n                                bg-white shadow-lg\">\n      {options.map(opt => (\n        <Listbox.Option\n          key={opt}\n          value={opt}\n          className={({ active }) =>\n            `cursor-pointer px-3 py-2 ${\n              active ? 'bg-blue-50 text-blue-900' : ''\n            }`\n          }\n        >\n          {opt}\n        </Listbox.Option>\n      ))}\n    </Listbox.Options>\n  </div>\n</Listbox>\n```\n\nColor Contrast Failures\n\nColor contrast is the single most common WCAG failure on the web, and AI regularly generates insufficient contrast, particularly for placeholder text, disabled states, and text over colored backgrounds. The axe-core integration in Layers 3 and 4 automatically catches most contrast violations.\n\nMotion Sensitivity Failures\n\nSome of those code examples above use [ motion-safe classes (from Tailwind)](https://tailwindcss.com/docs/animation#supporting-reduced-motion) on the chevron rotation, but don’t use them on the\n\n`transition-colors`\n\nfor buttons and links. The distinction: `prefers-reduced-motion`\n\nmedia queries best address spatial movement, transforms, position changes, and scaling. Simple color transitions don’t involve spatial movement and are generally safe. If your component includes sliding panels, expanding animations, or transform-based transitions, you should use `prefers-reduced-motion`\n\nmedia queries (or the special Tailwind classes that help) to reduce or remove that movement.Costs and Limits\n\nAdding accessibility constraints to AI-generated components adds **3-8 minutes** per component:\n\n- Workspace config setup (once)\n- ARIA review\n- Keyboard verification\n- Then: Iteration when the constraints are ignored\n\nComparing this to remediation after the fact is much more efficient. Discovering issues via audit, diagnosing root causes, refactoring the DOM, adding ARIA, implementing keyboard handlers, and writing tests take about **45-90 minutes** per component.\n\nThe five layers overlap deliberately. axe-core in a component test and axe-core in Playwright catch many of the same violations. Combined automated coverage is probably 70-85% of real-world issues. The rest of the meaningful labels, correct focus logic, live region timing, reading order, and cognitive load require manual testing with real assistive technologies and real users.\n\nSome AI tools are actively building accessibility into their pipelines. v0’s approach, generating Radix-based components by default, is the strongest example: it solves the problem architecturally by never generating raw `<div>`\n\ninteractives in the first place. As more tools adopt similar approaches, the severity of the problem described here will diminish. But even the best generation pipeline benefits from verification, and the enforcement system described here provides exactly that: proof, on every commit, that shipped code works.\n\nThe two highest-leverage interventions are `eslint-plugin-jsx-a11y`\n\nset to `error`\n\nin CI and an architectural decision to use Headless UI, Radix, or React Aria for interactive components. They prevent more failures than any amount of prompt engineering because they work regardless of which AI tool generated the code, whether a prompt was involved, or whether the developer thought about accessibility at all. Everything else is refinement on that foundation.\n\nThe accessibility tree reflects whatever DOM you give it. Make it good.", "url": "https://wpnews.pro/news/ai-generated-ui-is-inaccessible-by-default", "canonical_source": "https://master.dev/blog/ai-generated-ui-is-inaccessible-by-default/", "published_at": "2026-07-16 09:38:32+00:00", "updated_at": "2026-07-16 09:55:29.758406+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-tools", "ai-ethics", "developer-tools"], "entities": ["Claude Code", "Codex", "Cursor", "Vercel", "v0", "shadcn/ui", "Radix", "ChatGPT"], "alternates": {"html": "https://wpnews.pro/news/ai-generated-ui-is-inaccessible-by-default", "markdown": "https://wpnews.pro/news/ai-generated-ui-is-inaccessible-by-default.md", "text": "https://wpnews.pro/news/ai-generated-ui-is-inaccessible-by-default.txt", "jsonld": "https://wpnews.pro/news/ai-generated-ui-is-inaccessible-by-default.jsonld"}}