惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

GbyAI
GbyAI
博客园 - 三生石上(FineUI控件)
S
Securelist
U
Unit 42
The Cloudflare Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Simon Willison's Weblog
Simon Willison's Weblog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
B
Blog
T
Tenable Blog
The Hacker News
The Hacker News
The Register - Security
The Register - Security
IT之家
IT之家
博客园 - 【当耐特】
Spread Privacy
Spread Privacy
P
Privacy & Cybersecurity Law Blog
博客园_首页
T
Tailwind CSS Blog
人人都是产品经理
人人都是产品经理
C
Cybersecurity and Infrastructure Security Agency CISA
Know Your Adversary
Know Your Adversary
NISL@THU
NISL@THU
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
阮一峰的网络日志
阮一峰的网络日志
T
Tor Project blog
C
CERT Recently Published Vulnerability Notes
Apple Machine Learning Research
Apple Machine Learning Research
Stack Overflow Blog
Stack Overflow Blog
T
Threat Research - Cisco Blogs
T
The Exploit Database - CXSecurity.com
V
Vulnerabilities – Threatpost
A
Arctic Wolf
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
V
V2EX
aimingoo的专栏
aimingoo的专栏
大猫的无限游戏
大猫的无限游戏
Scott Helme
Scott Helme
L
LINUX DO - 热门话题
Cyberwarzone
Cyberwarzone
V
Visual Studio Blog
月光博客
月光博客
爱范儿
爱范儿
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
美团技术团队
G
GRAHAM CLULEY
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
H
Heimdal Security Blog
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO

Hacker News - Newest: "AI"

AI can't read an investor deck AI as an attorney? Student uses ChatGPT, Gemini to sue UW over alleged racial discrimination Hacking MCP Servers in AI Systems – The Rug Pull: Tool Changes After Approval GitHub - MeepCastana/KubeezCut: Free Web based video editor GitHub - GenAI-Gurus/awesome-eu-ai-act: Curated tools, official sources, OSS, templates, and guides for EU AI Act compliance. Can AI judge journalism? A Thiel-backed startup says yes, even if it risks chilling whistleblowers Coming soon: 10 Things That Matter in AI Right Now DARPA built an AI to fact-check enemy weapons claims What explains heterogeneity in AI adoption? When AI Meets Muscle: Context-Aware Electrical Stimulation Promises a New Way to Guide Human Movements - Department of Computer Science AI Changed How We Build. It Did Not Change What Matters. Linux rules on using AI-generated code - Copilot is OK, but humans must take 'full responsibility for the… Meta spins up AI version of Mark Zuckerberg to engage with employees Code Mode: Let Your AI Write Programs, Not Just Call Tools | TanStack Blog GitHub - Delavalom/graft: Go framework for building AI agents. Type-safe tools, multi-provider (OpenAI, Anthropic, Gemini, Bedrock), zero vendor SDKs. India's TCS tops estimates, says new AI models did not dent services demand Gen Z's fading AI hype Strong feeling: we are in a folded AI reality GitHub - machinarii/total-recall-catalog: A reference catalog of latest knowledge retrieval, memory & RAG systems GitHub - mensfeld/code-on-incus: Give each AI agent its own isolated machine with root, Docker, and systemd. Active defense detects and stops threats automatically.. Quantization, LoRA, and the 8% Problem: Benchmarking Local LLMs for Production AI Iran war: We spoke to the man making Lego-style AI videos that experts say are powerful propaganda Powell, Bessent discussed Anthropic's Mythos AI cyber threat with major U.S. banks GitHub - immartian/bellamem: Persistent belief-graph memory for AI agents. Retrieves decisive context by importance — not recency, not RAG, not /compact. recursive-mode: The Repo-Native Operating System for AI Engineering After the attack on Sam Altman's home, will AI CEO's go on the offensive? The biggest advance in AI since the LLM Opus 4.6 vs GPT 5.4 One Prompt Unity World Generation Test “AI polls” are fake polls Client Challenge Can AI be a 'child of God'? Inside Anthropic's meeting with Christian leaders How to Switch AI Chatbots and Why You Might Want To GitHub - MattMessinger1/agentic_refund_guardrail: Safe refund policy layer for AI agents — Python + TypeScript. Same behavior, shared tests. Adam/papers/emergent_values_whitepaper.md at master · strangeadvancedmarketing/Adam Ask HN: How do you stop playing 20 questions with your AI coding tools How far can automation and AI support psychotherapy? - @theU GitHub - stagas/rtdiff: realtime git diff gui and AI-assisted commits A Mac Studio for Local AI — 6 Months Later A History of the Early Years of AI at the University of Edinburgh Why AI Coding Tools Still Feel Stuck on Localhost MSN AI Datacenters Are Becoming Strategic Targets twitter.com Penn Researchers Use AI to Surface Unreported GLP-1 Side Effects in Reddit Posts Show HN: MoodSense AI (ML and FastAPI and Gradio, Deployed on Hugging Face) Moodsense Ai - a Hugging Face Space by aman179102 AI models are terrible at betting on soccer—especially xAI Grok GitHub - xialeistudio/echoic GitHub - HimashaHerath/github-dev-wrapped: AI-powered weekly GitHub activity reports deployed to GitHub Pages GitHub - alejandrobalderas/claude-code-from-source: Architecture, patterns & internals of Anthropic's AI coding agent — reverse-engineered from source maps AI and Tech brief: Ireland ascendant GitHub - Titovilal/context0: Context0 - Never Surrender Training for a Marathon with an AI Coach: What Worked and What Didn't Cyber Pulse: Agentic Intel - Apps on Google Play I Built an AI PR Reviewer That Catches Bugs by Not Looking for Bugs Gen Z workers are so fearful AI will take their job they’re intentionally sabotaging their company’s AI rollout | Fortune How AI Is Reimagining the Game of Golf–For Both Players and Courses GitHub - nattergabriel/reseed: A CLI tool for managing and distributing agent skills across projects Is SVG the final frontier? My AI workflow evolved from prompts to a near-autonomous workflow MLSharp Help - 3DGS Viewer & Generator I put my cognitive field based AI's runtime on GitHub Is Numble the first AI-proof game? A3: Kubernetes for autonomous AI agent fleets | Emergent Principles Deepali Vyas ("The Elite Recruiter") GitHub - msmarkgu/RelayFreeLLM: A restful API designed to route user prompts to various AI model providers. Unionized ProPublica staff are on strike over AI, layoffs, and wages Unleashing the Advantage of Quantum AI We're heading for an AI-fueled 'dementia crisis,' brain scientist warns The AI-Assisted Breach of Mexico's Government Infrastructure [pdf] GitHub - stef41/lmscan: 🔍 Detect AI-generated text and fingerprint which LLM wrote it. Open-source GPTZero alternative. Zero dependencies, works offline. MSN GitHub - visionscaper/collabmem: Enabling long-term collaboration with Agentic AI - building up episodic and world model memory over time with in-context awareness We gave an AI a 3 year retail lease in SF and asked it to make a profit | Andon Labs AI Code is Hollowing Out Open Source, and Maintainers are Looking the Other Way What leaked "SteamGPT" files could mean for the PC gaming platform's use of AI AI is the boss at this retail store. What could go wrong? GitHub - Wuzu11517/agentic-proxy: Local proxy meant to help reduce With Drones, Geophysics and ArtificiaI Intelligence, Researchers Prepare to Do Battle Against Land Mines A Single Operator, Two AI Platforms, Nine Government Agencies: The Full Technical Report 在 Steam 上购买 FriedrichAI: Offline AI 立省 10% GitHub - inevolin/resume-cli: Hit Claude usage limits? Resume any AI coding session elsewhere. Switch tools at zero friction. GitHub - atripati/ark: AI Runtime Kernel — a context operating system for AI agents. Eliminates tool bloat, loads only what’s needed, and gives LLMs their reasoning space back. How to Build a Secure AI PR Reviewer with Claude, GitHub Actions, and JavaScript This Startup Wants You to Pay Up to Talk With AI Versions of Human Experts Intel Arc Pro B70 Brings 32GB VRAM to Local AI for $949 WordPress 7.0: The Good, the AI, and the Still Missing AI on the couch: Anthropic gives Claude 20 hours of psychiatry IatroBench: Pre-Registered Evidence of Iatrogenic Harm from AI Safety Measures AI Agents Know About Supabase. They Don't Always Use It Right. The history and future of AI at Google, with Sundar Pichai Inside an AI‑enabled device code phishing campaign How Meta Used AI to Map Tribal Knowledge in Large-Scale Data Pipelines AI for Systems: Using LLMs to Optimize Database Query Execution Forecasting the Economic Effects of AI Introducing Tinker: Play with AI, bring your ideas to life AI sheds light on an ancient gaming mystery People really hate AI but not as much as Iran—or Democrats | Fortune What is an AI Product Engineer? Phoebe Gates wants her $185 million AI startup to succeed with 'no ties to my privilege or my last name': 'I have a chip on my shoulder' | Fortune
AI-Generated UI Is Inaccessible by Default
Durgesh Rajubhai Pawar on April 13, 2026 · 2026-06-24 · via Hacker News - Newest: "AI"

A five-layer enforcement system for semantic correctness in LLM-generated React components

These 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, name none, focusable false. For screen reader users, keyboard navigators, and voice control users, the component effectively doesn’t exist.

This 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.

A caveat: the landscape is not monolithic. Specialized tools like Vercel’s v0 have begun hardcoding accessible primitives into their generation pipelines v0 outputs shadcn/ui components built on Radix, which means its output inherits Radix’s accessibility contracts by default.

This 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> soup 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.

The Problem, Demonstrated

Here 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.

The browser’s accessibility tree for this component

group
text "Settings"
group
group
text "Account"
image (no accessible name)
group
text "Profile"
text "Security"

What’s broken:

  1. No landmark. The outer <div> produces no navigation role. Screen reader users can’t jump to this section via landmark navigation.
  2. No heading. “Settings” is a styled <div>, not an <h2>. Navigation by heading will never find it.
  3. No list structure. The items aren’t in <ul>/<li>. No “list, 2 items” context.
  4. Wrong role on the toggle. The Account <div> maps to generic. Not announced as interactive.
  5. Not focusable. <div> elements aren’t in the tab order. Keyboard users can’t reach the toggle.
  6. No expanded/collapsed state. No aria-expanded. The chevron rotation is visual-only.
  7. No control relationship. No aria-controls linking the trigger to the panel.
  8. No keyboard interaction. No onKeyDown. Even if focused, Enter and Space do nothing.
  9. Unlabeled icon. The SVG lacks both aria-hidden and an accessible name.
  10. Fake links. Profile and Security are <div> elements with click handlers. No link role, not focusable, can’t be opened in a new tab.

Ten 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.

In my testing across several tools over the past two months, this pattern was pervasive. <div onClick> instead of <button> or <a> appeared 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

General-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.

Why This Happens: The Accessibility Tree

When 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.

But 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-ARIAHTML-AAM, and Core AAM specifications. When a screen reader traverses a page, it reads this tree, not the DOM, not the render tree. Each node has four properties:

  • Role: What is this thing? (buttonlinknavigationtab)
  • Name: What is it called? (computed from text content, aria-labelalt)
  • State: What condition is it in? (expandedselectedcheckeddisabled)
  • Value: What data does it hold? (input value, progress value)

The 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 and hover:bg-gray-800. The accessibility tree cares about <button>aria-expanded, and aria-controls. CSS can make a <div> look like a button. Only HTML semantics can make it be one.

<button>Account</button>
<!--
  role: button | name: "Account" | focusable: true | activation: Enter, Space
-->Code language: HTML, XML (xml)
<div onClick={handleClick}>Account</div>
<!--
  ​​role: generic | name: none | focusable: false | activation: none
-->Code language: HTML, XML (xml)

Same pixels. One is a door. The other is a painting of a door.

Why AI Gets This Wrong

The exact mechanisms are difficult to isolate empirically, but several plausible factors explain the consistent bias:

  • Training data: Most React code on GitHub uses <div> elements 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}> is fewer tokens than <button type="button" aria-expanded={isOpen} aria-controls="panel-id">. 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.

These are informed hypotheses. But the pattern they predict high visual fidelity, near-zero semantic fidelity, matches what I observe consistently.

Understanding this makes the fix clear: you need to enforce semantic correctness at every stage where it could be lost.

Layer 1: Prompt Constraints

An unconstrained prompt produces unconstrained output. The model converges on its statistical default <div> soup. A constrained prompt narrows the output space to semantically valid components.

This prompt should not be typed manually each time. Bake it into your workspace context: Cursor reads a .cursorrules file from your project root. GitHub Copilot supports .github/copilot-instructions.md. 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.

# Component Generation Rules

You 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.

Here are some other ideas for prompting from Eric Bailey.

Here is the sidebar regenerated with these constraints:

The accessibility tree:

navigation "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)

Every 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:

Same <button>, same aria-expanded, same aria-controls, 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 for Vue, SvelteKit’s built-in a11y warnings, and axe-core works against any rendered DOM regardless of origin).

When the Model Ignores Your Constraints

This happens regularly. Three strategies:

  1. 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.”
  2. 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.
  3. Manual checklist: Before committing, are there interactive elements <button> or <a>? Do toggles have aria-expanded? Can you Tab to and activate every control? Do landmarks and headings exist? Do icons have aria-hidden? Two minutes.

When 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.

Layer 2: Static Analysis

You can be checking your code directly with tools. A great option for the code we’re working with so far is ESLint with the eslint-plugin-jsx-a11y plugin.

npm install --save-dev eslint-plugin-jsx-a11yCode language: Bash (bash)

Here’s an example configuration that errors on issues we know are preventable through better code.

export default [
  {
    plugins: { 'jsx-a11y': jsxA11y },
    rules: {
      'jsx-a11y/click-events-have-key-events': 'error',
      'jsx-a11y/no-static-element-interactions': 'error',
      'jsx-a11y/no-noninteractive-element-interactions': 'error',
      'jsx-a11y/no-noninteractive-element-to-interactive-role': 'error',
      'jsx-a11y/alt-text': 'error',
      'jsx-a11y/aria-props': 'error',
      'jsx-a11y/aria-proptypes': 'error',
      'jsx-a11y/anchor-is-valid': 'error',
      'jsx-a11y/interactive-supports-focus': 'error',
      'jsx-a11y/label-has-associated-control': 'error',
      'jsx-a11y/role-has-required-aria-props': 'error',
      'jsx-a11y/role-supports-aria-props': 'error',
    },
  },
];Code language: JavaScript (javascript)

Now when the AI generates poor code like <div onClick>, we’ll get an error message when calling this tool like:

error Visible, non-interactive elements with click handlers must have at least one keyboard listener jsx-a11y/click-events-have-key-events

With pre-commit hooks and CI enforcement, the code cannot ship.

Static 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.

Layer 3: Runtime Testing

We can add another layer on top of static analysis: actual browser testing. A tool like Playwright can help here along with a plugin specifically for accessibility testing, @axe-core/playwright.

npm install --save-dev jest-axe @axe-core/playwrightCode language: Bash (bash)

Here’s an example test that mounts the actual components and tests things in a browser.

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe, toHaveNoViolations } from 'jest-axe';
import { SettingsSidebar } from './SettingsSidebar';

expect.extend(toHaveNoViolations);

describe('SettingsSidebar', () => {
  it('has no axe violations in collapsed state', async () => {
    const { container } = render(<SettingsSidebar />);
    expect(await axe(container)).toHaveNoViolations();
  });

  it('has no axe violations in expanded state', async () => {
    const { container } = render(<SettingsSidebar />);
    await userEvent.setup().click(
      screen.getByRole('button', { name: /account/i })
    );

    // axe evaluates the DOM at a point in time. Scanning during
    // a React re-render or CSS transition can produce results 
    // against intermediate DOM states. Wait for it to settle.
    await waitFor(async () => {
      expect(await axe(container)).toHaveNoViolations();
    });
  });

  it('supports full keyboard navigation', async () => {
    render(<SettingsSidebar />);
    const user = userEvent.setup();

    await user.tab();
    expect(
      screen.getByRole('button', { name: /account/i })
    ).toHaveFocus();

    await user.keyboard('{Enter}');
    expect(
      screen.getByRole('button', { name: /account/i })
    ).toHaveAttribute('aria-expanded', 'true');

    await user.tab();
    expect(
      screen.getByRole('link', { name: /profile/i })
    ).toHaveFocus();
  });

  it('exposes correct roles and states', () => {
    render(<SettingsSidebar />);

    expect(
      screen.getByRole('navigation', { name: /settings/i })
    ).toBeInTheDocument();

    expect(
      screen.getByRole('button', { name: /account/i })
    ).toHaveAttribute('aria-expanded', 'false');
  });
});Code language: JavaScript (javascript)

These tests query by role and accessible name. This is not a stylistic choice. If getByRole('button', { name: /account/i }) fails, a screen reader can’t find that element either. The testing Library’s query API is itself an accessibility assertion.

What axe-core Catches and What It Doesn’t

axe-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.

It cannot assess whether your labels are meaningful; it checks that an aria-label exists, 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.

axe-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.

Layer 4: CI Integration

Continuous 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).

GitHub Actions isn’t the only option, but it’s very popular because GitHub itself is. Here is an example workflow that runs our static-analysis linting and also includes a new browser test that loads the actual page and clicks things.

name: Accessibility Checks
on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - name: ESLint a11y rules
        run: npx eslint 'src/**/*.{ts,tsx}' --max-warnings 0

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - name: Component accessibility tests
        run: npx jest --testPathPattern='\.test\.'

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - name: Playwright axe audit
        run: npx playwright test --grep @a11yCode language: YAML (yaml)

And here’s an End-to-End test example (i.e. see how it visits actual URLs).

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('settings page passes axe audit @a11y', async ({ page }) => {
  await page.goto('/settings');
  await page.waitForLoadState('networkidle');

  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
    .analyze();
  expect(results.violations).toEqual([]);
});

test('sidebar keyboard navigation @a11y', async ({ page }) => {
  await page.goto('/settings');
  await page.keyboard.press('Tab');

  // Playwright locators are lazy-evaluated — :focus resolves to
  // whatever element has focus at assertion time, not capture time.
  const focused = page.locator(':focus');
  await expect(focused).toHaveRole('button');
  await expect(focused).toHaveAttribute('aria-expanded', 'false');

  await page.keyboard.press('Space');
  await expect(focused).toHaveAttribute('aria-expanded', 'true');

  await page.keyboard.press('Tab');
  await expect(page.locator(':focus')).toHaveRole('link');
});Code language: JavaScript (javascript)

Layer 5: Accessible Component Abstractions

The deepest solution is architectural. Instead of relying on every prompt to produce correct primitives, use libraries that encode accessibility into their API contracts.

  • Headless UI 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 covers roughly thirty unstyled primitives with a composable component API the pragmatic middle ground for most projects. 
  • 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> elements.

Here’s an example of using Headless UI (bringing Tailwind for styling).

import { Disclosure } from '@headlessui/react';

function SettingsSidebar() {
  return (
    <nav aria-label="Settings" className="w-64 bg-gray-900 
                                          text-white h-screen p-4">
      <h2 className="text-xl font-bold mb-6 px-2">Settings</h2>
      
      <ul className="space-y-2 list-none" role="list">
        {sections.map(section => (
          <li key={section.id}>
            <Disclosure>
              {({ open }) => (
                <>
                  <Disclosure.Button
                    className="flex w-full items-center 
                               justify-between px-2 py-2 rounded-lg 
                               text-sm font-medium hover:bg-gray-800 
                               focus-visible:ring-2 
                               focus-visible:ring-blue-500
                               transition-colors"
                  >
                    {section.label}
                    <ChevronIcon
                      aria-hidden="true"
                      className={`w-4 h-4 
                                 motion-safe:transition-transform
                                 motion-safe:duration-200 ${
                                   open ? 'rotate-180' : ''
                                 }`}
                    />
                  </Disclosure.Button>

                  <Disclosure.Panel className="ml-4 mt-1 space-y-1">
                    <ul className="list-none" role="list">
                      {section.links.map(link => (
                        <li key={link.href}>
                          <a
                            href={link.href}
                            className="block px-2 py-1.5 text-sm 
                                       text-gray-300 hover:text-white 
                                       hover:bg-gray-800 rounded 
                                       focus-visible:ring-2
                                       focus-visible:ring-blue-500
                                       transition-colors"
                          >
                            {link.label}
                          </a>
                        </li>
                      ))}
                    </ul>
                  </Disclosure.Panel>
                </>
              )}
            </Disclosure>
          </li>
        ))}
      </ul>
    </nav>
  );
}Code language: JavaScript (javascript)

By 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.

Common AI Failure Patterns

Three additional patterns beyond the sidebar merit attention because they involve more complex accessibility requirements.

The Invisible Modal

{isOpen && (
  <div className="fixed inset-0 z-50 flex items-center 
                  justify-center bg-black/50">
    <div className="rounded-xl bg-white p-6 shadow-xl">
      <div className="text-lg font-bold">Confirm</div>
      <div className="mt-2">Are you sure?</div>
      <div className="mt-4 flex gap-2">
        <div className="cursor-pointer rounded bg-red-500 px-4 py-2 
                        text-white" onClick={onConfirm}>Yes</div>
        <div className="cursor-pointer rounded bg-gray-300 px-4 py-2" 
             onClick={onClose}>No</div>
      </div>
    </div>
  </div>
)}Code language: HTML, XML (xml)

Issues:

  • No role="dialog"
  • No aria-modal
  • No label
  • No focus trap
  • No Escape handler
  • No focus restoration

The native <dialog> element with showModal() provides focus trapping, Escape to close, aria-modal, and backdrop handling for free. Better:

<dialog
  ref={dialogRef}
  className="rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50"
  aria-labelledby="dialog-title"
  onClose={onClose}
>
  <h2 id="dialog-title" className="text-lg font-bold">Confirm</h2>
  <p className="mt-2">Are you sure?</p>
  <div className="mt-4 flex gap-2">
    <button type="button"
            className="rounded bg-red-500 px-4 py-2 text-white 
                       hover:bg-red-600 focus-visible:ring-2 
                       focus-visible:ring-red-400"
            onClick={onConfirm}>Yes</button>
    <button type="button"
            className="rounded bg-gray-300 px-4 py-2 
                       hover:bg-gray-400 focus-visible:ring-2 
                       focus-visible:ring-gray-500"
            onClick={onClose}>No</button>
  </div>
</dialog>
Code language: HTML, XML (xml)

One caveat: native <dialog> has 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.

The Icon-Only Button

<div className="cursor-pointer rounded-full p-2 hover:bg-gray-100"
     onClick={onClose}>
  <svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" 
       stroke="currentColor">
    <path strokeLinecap="round" strokeLinejoin="round" 
          strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
  </svg>
</div>Code language: HTML, XML (xml)

Issues:

  • The role is generic instead of button
  • Entirely empty name – invisible to assistive technology
  • Unfocusable

Better to use the correct native elements and attributes to help with naming:

<button type="button"
        aria-label="Close dialog"
        className="rounded-full p-2 hover:bg-gray-100 
                   focus-visible:ring-2 focus-visible:ring-blue-500"
        onClick={onClose}>
  <svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 24 24" 
       fill="none" stroke="currentColor">
    <path strokeLinecap="round" strokeLinejoin="round" 
          strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
  </svg>
</button>Code language: HTML, XML (xml)

The Custom Select

<div className="relative">
  <div className="cursor-pointer rounded border px-3 py-2" 
       onClick={() => setIsOpen(!isOpen)}>
    {selected || 'Choose an option'}
  </div>
  {isOpen && (
    <div className="absolute mt-1 w-full rounded border bg-white 
                    shadow-lg">
      {options.map(opt => (
        <div key={opt} className="cursor-pointer px-3 py-2 
                                  hover:bg-blue-50"
             onClick={() => { setSelected(opt); setIsOpen(false); }}>
          {opt}
        </div>
      ))}
    </div>
  )}
</div>Code language: HTML, XML (xml)

Issues:

  • No listbox role
  • No aria-expanded attribute
  • No aria-selected attribute
  • No aria-activedescendant attribute
  • No keyboard navigation

A correct implementation requires ~150 lines of keyboard management. My suggestion is to use a headless library:

import { Listbox } from '@headlessui/react';

<Listbox value={selected} onChange={setSelected}>
  <Listbox.Label className="block text-sm font-medium text-gray-700">
    Choose an option
  </Listbox.Label>
  <div className="relative mt-1">
    <Listbox.Button className="relative w-full cursor-pointer rounded 
                               border bg-white py-2 pl-3 pr-10 
                               text-left focus-visible:ring-2 
                               focus-visible:ring-blue-500">
      {selected || 'Select...'}
    </Listbox.Button>
    <Listbox.Options className="absolute mt-1 w-full rounded border 
                                bg-white shadow-lg">
      {options.map(opt => (
        <Listbox.Option
          key={opt}
          value={opt}
          className={({ active }) =>
            `cursor-pointer px-3 py-2 ${
              active ? 'bg-blue-50 text-blue-900' : ''
            }`
          }
        >
          {opt}
        </Listbox.Option>
      ))}
    </Listbox.Options>
  </div>
</Listbox>Code language: JavaScript (javascript)

Color Contrast Failures

Color 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.

Motion Sensitivity Failures

Some of those code examples above use  motion-safe classes (from Tailwind) on the chevron rotation, but don’t use them on the transition-colors for buttons and links. The distinction: prefers-reduced-motion media 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 media queries (or the special Tailwind classes that help) to reduce or remove that movement.

Costs and Limits

Adding accessibility constraints to AI-generated components adds 3-8 minutes per component:

  • Workspace config setup (once)
  • ARIA review
  • Keyboard verification
  • Then: Iteration when the constraints are ignored

Comparing 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.

The 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.

Some 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> interactives 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.

The two highest-leverage interventions are eslint-plugin-jsx-a11y set to error in 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.

The accessibility tree reflects whatever DOM you give it. Make it good.