{"id":9221,"date":"2026-04-13T10:17:39","date_gmt":"2026-04-13T15:17:39","guid":{"rendered":"https:\/\/frontendmasters.com\/blog\/?p=9221"},"modified":"2026-04-13T10:17:40","modified_gmt":"2026-04-13T15:17:40","slug":"ai-generated-ui-is-inaccessible-by-default","status":"publish","type":"post","link":"https:\/\/frontendmasters.com\/blog\/ai-generated-ui-is-inaccessible-by-default\/","title":{"rendered":"AI-Generated UI Is Inaccessible by Default"},"content":{"rendered":"\n<p class=\"has-medium-font-size\"><strong>A five-layer enforcement system for semantic correctness in LLM-generated React components<\/strong><\/p>\n\n\n\n<p id=\"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 <em>looks<\/em> correct: smooth hover states, rotating chevrons, harmonious spacing. But take a look at the browser&#8217;s accessibility tree in DevTools. Chances are the root side element tree receives: role&nbsp;<code>generic<\/code>, name&nbsp;<code>none<\/code>, focusable&nbsp;<code>false<\/code>. For screen reader users, keyboard navigators, and voice control users, the component effectively doesn&#8217;t exist.<\/p>\n\n\n\n<p>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&#8217;s what most AI tools emit, but the accessibility tree doesn&#8217;t care about your framework. It cares about the DOM it receives.<\/p>\n\n\n\n<p class=\"learn-more\">A caveat: the landscape is not monolithic. Specialized tools like Vercel&#8217;s <a href=\"https:\/\/v0.app\/\">v0<\/a> have begun hardcoding accessible primitives into their generation pipelines v0 outputs&nbsp;<a href=\"https:\/\/ui.shadcn.com\/\">shadcn\/ui<\/a>&nbsp;components built on Radix, which means its output inherits Radix&#8217;s accessibility contracts by default.<\/p>\n\n\n\n<p>This is the right architectural approach, and it&#8217;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&nbsp;<code>&lt;div&gt;<\/code>&nbsp;soup this article dissects. And even v0&#8217;s output benefits from the verification layers described here, because no generation pipeline eliminates the need to confirm that what is shipped actually works.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"1-the-problem-demonstrated\">The Problem, Demonstrated<\/h2>\n\n\n\n<p>Here is a navigation sidebar representative of what general-purpose AI code generation tools produce. I&#8217;ve tested variations of this prompt across multiple tools; the structural patterns are consistent.<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_ogzGxjX\" src=\"\/\/codepen.io\/anon\/embed\/ogzGxjX?height=450&amp;theme-id=1&amp;slug-hash=ogzGxjX&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed ogzGxjX\" title=\"CodePen Embed ogzGxjX\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>The browser&#8217;s accessibility tree for this component<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">group<br>  text \"Settings\"<br>  group<br>    group<br>      text \"Account\"<br>      image (no accessible name)<br>    group<br>      text \"Profile\"<br>      text \"Security\"<\/pre>\n\n\n\n<p>What&#8217;s broken:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>No landmark.<\/strong>&nbsp;The outer&nbsp;<code>&lt;div&gt;<\/code>&nbsp;produces no navigation role. Screen reader users can&#8217;t jump to this section via landmark navigation.<\/li>\n\n\n\n<li><strong>No heading.<\/strong>&nbsp;&#8220;Settings&#8221; is a styled&nbsp;<code>&lt;div&gt;<\/code>, not an&nbsp;<code>&lt;h2&gt;<\/code>. Navigation by heading will never find it.<\/li>\n\n\n\n<li><strong>No list structure.<\/strong>&nbsp;The items aren&#8217;t in&nbsp;<code>&lt;ul&gt;<\/code>\/<code>&lt;li&gt;<\/code>. No &#8220;list, 2 items&#8221; context.<\/li>\n\n\n\n<li><strong>Wrong role on the toggle.<\/strong>&nbsp;The Account&nbsp;<code>&lt;div&gt;<\/code>&nbsp;maps to&nbsp;<code>generic<\/code>. Not announced as interactive.<\/li>\n\n\n\n<li><strong>Not focusable.<\/strong>&nbsp;<code>&lt;div&gt;<\/code>&nbsp;elements aren&#8217;t in the tab order. Keyboard users can&#8217;t reach the toggle.<\/li>\n\n\n\n<li><strong>No expanded\/collapsed state.<\/strong>&nbsp;No&nbsp;<code>aria-expanded<\/code>. The chevron rotation is visual-only.<\/li>\n\n\n\n<li><strong>No control relationship.<\/strong>&nbsp;No&nbsp;<code>aria-controls<\/code>&nbsp;linking the trigger to the panel.<\/li>\n\n\n\n<li><strong>No keyboard interaction.<\/strong>&nbsp;No&nbsp;<code>onKeyDown<\/code>. Even if focused, Enter and Space do nothing.<\/li>\n\n\n\n<li><strong>Unlabeled icon.<\/strong>&nbsp;The SVG lacks both&nbsp;<code>aria-hidden<\/code>&nbsp;and an accessible name.<\/li>\n\n\n\n<li><strong>Fake links.<\/strong>&nbsp;Profile and Security are&nbsp;<code>&lt;div&gt;<\/code>&nbsp;elements with click handlers. No&nbsp;<code>link<\/code>&nbsp;role, not focusable, can&#8217;t be opened in a new tab.<\/li>\n<\/ol>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>In my testing across several tools over the past two months, this pattern was pervasive.&nbsp;<code>&lt;div onClick&gt;<\/code>&nbsp;instead of&nbsp;<code>&lt;button&gt;<\/code>&nbsp;or&nbsp;<code>&lt;a&gt;<\/code>&nbsp;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<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why This Happens: The Accessibility Tree<\/h2>\n\n\n\n<p>When the browser receives your HTML and CSS, it builds two primary representations. The&nbsp;<strong>render tree<\/strong>&nbsp;(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&#8217;s the one AI-generated code handles well.<\/p>\n\n\n\n<p>But the browser also constructs a separate, parallel structure: the&nbsp;<strong>Accessibility Tree<\/strong>. This is a filtered, semantically-enriched representation of the DOM, built according to the&nbsp;<a href=\"https:\/\/www.w3.org\/TR\/wai-aria-1.2\/\">WAI-ARIA<\/a>,&nbsp;<a href=\"https:\/\/www.w3.org\/TR\/html-aam-1.0\/\">HTML-AAM<\/a>, and&nbsp;<a href=\"https:\/\/www.w3.org\/TR\/core-aam-1.2\/\">Core AAM<\/a>&nbsp;specifications. When a screen reader traverses a page, it reads this tree, not the DOM, not the render tree. Each node has four properties:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Role<\/strong>: What is this thing? (<code>button<\/code>,&nbsp;<code>link<\/code>,&nbsp;<code>navigation<\/code>,&nbsp;<code>tab<\/code>)<\/li>\n\n\n\n<li><strong>Name<\/strong>: What is it called? (computed from text content,&nbsp;<code>aria-label<\/code>,&nbsp;<code>alt<\/code>)<\/li>\n\n\n\n<li><strong>State<\/strong>: What condition is it in? (<code>expanded<\/code>,&nbsp;<code>selected<\/code>,&nbsp;<code>checked<\/code>,&nbsp;<code>disabled<\/code>)<\/li>\n\n\n\n<li><strong>Value<\/strong>: What data does it hold? (input value, progress value)<\/li>\n<\/ul>\n\n\n\n<p>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&nbsp;<code>cursor-pointer<\/code>&nbsp;and&nbsp;<code>hover:bg-gray-800<\/code>. The accessibility tree cares about&nbsp;<code>&lt;button&gt;<\/code>,&nbsp;<code>aria-expanded<\/code>, and&nbsp;<code>aria-controls<\/code>. CSS can make a&nbsp;<code>&lt;div&gt;<\/code>&nbsp;<em>look<\/em>&nbsp;like a button. Only HTML semantics can make it&nbsp;<em>be<\/em>&nbsp;one.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">button<\/span>&gt;<\/span>Account<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">button<\/span>&gt;<\/span>\n<span class=\"hljs-comment\">&lt;!--\n  role: button | name: \"Account\" | focusable: true | activation: Enter, Space\n--&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">onClick<\/span>=<span class=\"hljs-string\">{handleClick}<\/span>&gt;<\/span>Account<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n<span class=\"hljs-comment\">&lt;!--\n  \u200b\u200brole: generic | name: none | focusable: false | activation: none\n--&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Same pixels. One is a door. The other is a painting of a door.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why AI Gets This Wrong<\/h2>\n\n\n\n<p>The exact mechanisms are difficult to isolate empirically, but several plausible factors explain the consistent bias:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Training data<\/strong>: Most React code on GitHub uses&nbsp;<code>&lt;div&gt;<\/code>&nbsp;elements with CSS classes. Semantic HTML and ARIA are underrepresented, which likely means they&#8217;re underrepresented in training corpora.<\/li>\n\n\n\n<li><strong>Feedback loops<\/strong>: When developers and RLHF evaluators assess AI output, they almost certainly evaluate visually. The feedback signal reinforces visual fidelity without penalizing semantic failures.<\/li>\n\n\n\n<li><strong>Token economics<\/strong>:&nbsp;<code>&lt;div onClick={fn}&gt;<\/code>&nbsp;is fewer tokens than&nbsp;<code>&lt;button type=\"button\" aria-expanded={isOpen} aria-controls=\"panel-id\"&gt;<\/code>. Absent specific constraints, the model has no incentive to spend extra tokens.<\/li>\n\n\n\n<li><strong>No AOM model<\/strong>: The model has no representation of the accessibility tree. It models what code&nbsp;<em>looks like<\/em>, not what code&nbsp;<em>means<\/em>&nbsp;to assistive technologies.<\/li>\n<\/ul>\n\n\n\n<p>These are informed hypotheses. But the pattern they predict high visual fidelity, near-zero semantic fidelity, matches what I observe consistently.<\/p>\n\n\n\n<p>Understanding this makes the fix clear: you need to enforce semantic correctness at every stage where it could be lost.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"iii-layer-1-prompt-constraints\">Layer 1: Prompt Constraints<\/h2>\n\n\n\n<p>An unconstrained prompt produces unconstrained output. The model converges on its statistical default&nbsp;<code>&lt;div&gt;<\/code>&nbsp;soup. A constrained prompt narrows the output space to semantically valid components.<\/p>\n\n\n\n<p><strong>This prompt should not be typed manually each time.<\/strong>&nbsp;Bake it into your workspace context: Cursor reads a&nbsp;<code>.cursorrules<\/code>&nbsp;file from your project root. GitHub Copilot supports&nbsp;<code>.github\/copilot-instructions.md<\/code>. Other tools have similar mechanisms. The prompt below belongs in one of those files, applied automatically to every generation. If your tool doesn&#8217;t support persistent context, you have a stronger argument for investing in Layers 2 5.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"># Component Generation Rules<br><br>You are generating a React component. Follow these rules strictly.<br><br>## HTML Semantics<br>- Use &lt;button&gt; for actions. Never &lt;div onClick&gt; or &lt;span onClick&gt;.<br>- Use &lt;a href=\"...\"&gt; for navigation. Never &lt;span onClick={navigate}&gt;.<br>- Use &lt;nav&gt;, &lt;main&gt;, &lt;aside&gt;, &lt;header&gt;, &lt;footer&gt; for landmarks.<br>- Use &lt;h1&gt;-&lt;h6&gt; in correct hierarchical order. Do not skip levels.<br>- Use &lt;ul&gt;\/&lt;ol&gt; with &lt;li&gt; for lists.<br>- Use &lt;table&gt;, &lt;thead&gt;, &lt;tbody&gt;, &lt;th&gt;, &lt;td&gt; for tabular data.<br>- Use &lt;form&gt;, &lt;fieldset&gt;, &lt;legend&gt;, &lt;label&gt; for forms.<br>- Use &lt;dialog&gt; for modal dialogs with its showModal() API.<br>- Use &lt;details&gt;\/&lt;summary&gt; for simple disclosures when appropriate.<br><br>## Accessibility<br>- Every interactive element must have an accessible name <br>  (visible text, aria-label, or aria-labelledby).<br>- Every form input must have an associated &lt;label&gt; or aria-label.<br>- Icon-only buttons: aria-label on button, aria-hidden on icon.<br>- Decorative images: alt=\"\" or aria-hidden=\"true\".<br>- Dynamic state: use aria-expanded, aria-selected, aria-checked, <br>  aria-current, aria-disabled as appropriate.<br>- Use aria-live=\"polite\" for status messages.<br>- Use aria-describedby for help text and error messages.<br><br>## Keyboard Interaction<br>- All interactive elements must be keyboard accessible.<br>- Use focus-visible styles. Never remove outlines without replacement.<br>- Composite widgets: arrow keys per WAI-ARIA Authoring Practices.<br>- Modals must trap focus and restore it on close.<br>- Escape must close overlays.<br><br>## Motion<br>- Respect prefers-reduced-motion. Use motion-safe: or <br>  motion-reduce: Tailwind variants on transitions involving <br>  spatial movement (transforms, position changes, scaling). <br>  Simple color transitions on hover\/focus are acceptable <br>  without motion guards.<br><br>## Library Preferences<br>- For complex patterns (tabs, combobox, dialog, listbox, menu), <br>  use Headless UI, Radix UI, or React Aria instead of building <br>  from scratch.<br>- Use Tailwind CSS for styling.<br>- Include focus-visible ring styles on all interactive elements.<br><br>## Testing<br>- Query elements using getByRole with accessible name, <br>  not getByTestId.<\/pre>\n\n\n\n<p class=\"learn-more\"><a href=\"https:\/\/ericwbailey.website\/published\/heres-how-to-instruct-a-llm-to-reference-the-aria-authoring-practices-guide\/\">Here are some other ideas<\/a> for prompting from Eric Bailey.<\/p>\n\n\n\n<p>Here is the sidebar regenerated with these constraints:<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_ByLwKVx\" src=\"\/\/codepen.io\/anon\/embed\/ByLwKVx?height=450&amp;theme-id=1&amp;slug-hash=ByLwKVx&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed ByLwKVx\" title=\"CodePen Embed ByLwKVx\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>The accessibility tree:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">navigation \"Settings\" <br>    heading \"Settings\" (level 2)<br>    list (2 items)<br>     listitem<br>        button \"Account\" (expanded)<br>        region \"Account\"<br>            list (2 items)<br>              listitem<br>                  link \"Profile\" <br>              listitem<br>                  link \"Security\"<br>     listitem<br>        button \"Preferences\" (collapsed)<\/pre>\n\n\n\n<p>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:<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_KwgXzbY\" src=\"\/\/codepen.io\/anon\/embed\/KwgXzbY?height=450&amp;theme-id=1&amp;slug-hash=KwgXzbY&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed KwgXzbY\" title=\"CodePen Embed KwgXzbY\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>Same&nbsp;<code>&lt;button&gt;<\/code>, same&nbsp;<code>aria-expanded<\/code>, same&nbsp;<code>aria-controls<\/code>, same accessibility tree. The remaining examples use React because that&#8217;s where AI generation is most prevalent, but the principles have equivalents in every ecosystem (<code>eslint-plugin-vuejs-accessibility<\/code>&nbsp;for Vue, SvelteKit&#8217;s built-in a11y warnings, and axe-core works against any rendered DOM regardless of origin).<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">When the Model Ignores Your Constraints<\/h3>\n\n\n\n<p>This happens regularly. Three strategies:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Targeted follow-up<\/strong>: Don&#8217;t regenerate from scratch. Prompt with a specific correction:&nbsp;<em>&#8220;The Account toggle is a div with onClick. Replace it with a button and add aria-expanded and aria-controls.&#8221;<\/em><\/li>\n\n\n\n<li><strong>Audit prompt<\/strong>: After generation:&nbsp;<em>&#8220;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.&#8221;<\/em>&nbsp;Models review code more reliably than they generate correct code from scratch.<\/li>\n\n\n\n<li><strong>Manual checklist<\/strong>: Before committing, are there interactive elements&nbsp;<code>&lt;button&gt;<\/code>&nbsp;or&nbsp;<code>&lt;a&gt;<\/code>? Do toggles have&nbsp;<code>aria-expanded<\/code>? Can you Tab to and activate every control? Do landmarks and headings exist? Do icons have&nbsp;<code>aria-hidden<\/code>? Two minutes.<\/li>\n<\/ol>\n\n\n\n<p>When Layer 1 is weak or absent, inline Copilot suggestions and tab completions, where there&#8217;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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"iv-layer-2-static-analysis\">Layer 2: Static Analysis<\/h2>\n\n\n\n<p>You can be checking your code directly with tools. A great option for the code we&#8217;re working with so far is <a href=\"https:\/\/eslint.org\/\">ESLint<\/a> with the <a href=\"https:\/\/github.com\/jsx-eslint\/eslint-plugin-jsx-a11y\">eslint-plugin-jsx-a11y<\/a> plugin.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"Bash\" data-shcb-language-slug=\"bash\"><span><code class=\"hljs language-bash\">npm install --save-dev eslint-plugin-jsx-a11y<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">Bash<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">bash<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Here&#8217;s an example configuration that errors on issues we know are preventable through better code. <\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">default<\/span> &#91;\n  {\n    <span class=\"hljs-attr\">plugins<\/span>: { <span class=\"hljs-string\">'jsx-a11y'<\/span>: jsxA11y },\n    <span class=\"hljs-attr\">rules<\/span>: {\n      <span class=\"hljs-string\">'jsx-a11y\/click-events-have-key-events'<\/span>: <span class=\"hljs-string\">'error'<\/span>,\n      <span class=\"hljs-string\">'jsx-a11y\/no-static-element-interactions'<\/span>: <span class=\"hljs-string\">'error'<\/span>,\n      <span class=\"hljs-string\">'jsx-a11y\/no-noninteractive-element-interactions'<\/span>: <span class=\"hljs-string\">'error'<\/span>,\n      <span class=\"hljs-string\">'jsx-a11y\/no-noninteractive-element-to-interactive-role'<\/span>: <span class=\"hljs-string\">'error'<\/span>,\n      <span class=\"hljs-string\">'jsx-a11y\/alt-text'<\/span>: <span class=\"hljs-string\">'error'<\/span>,\n      <span class=\"hljs-string\">'jsx-a11y\/aria-props'<\/span>: <span class=\"hljs-string\">'error'<\/span>,\n      <span class=\"hljs-string\">'jsx-a11y\/aria-proptypes'<\/span>: <span class=\"hljs-string\">'error'<\/span>,\n      <span class=\"hljs-string\">'jsx-a11y\/anchor-is-valid'<\/span>: <span class=\"hljs-string\">'error'<\/span>,\n      <span class=\"hljs-string\">'jsx-a11y\/interactive-supports-focus'<\/span>: <span class=\"hljs-string\">'error'<\/span>,\n      <span class=\"hljs-string\">'jsx-a11y\/label-has-associated-control'<\/span>: <span class=\"hljs-string\">'error'<\/span>,\n      <span class=\"hljs-string\">'jsx-a11y\/role-has-required-aria-props'<\/span>: <span class=\"hljs-string\">'error'<\/span>,\n      <span class=\"hljs-string\">'jsx-a11y\/role-supports-aria-props'<\/span>: <span class=\"hljs-string\">'error'<\/span>,\n    },\n  },\n];<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Now when the AI generates poor code like <code>&lt;div onClick&gt;<\/code>, we&#8217;ll get an error message when calling this tool like:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted has-vivid-red-color has-text-color has-link-color wp-elements-64cd38301b59b6660daf03eccbd7fc26\"><strong>error Visible, non-interactive elements with click handlers must have at least one keyboard listener jsx-a11y\/click-events-have-key-events<\/strong><\/pre>\n\n\n\n<p>With pre-commit hooks and CI enforcement, the code cannot ship. <\/p>\n\n\n\n<p>Static analysis is fast and cheap, but it can only evaluate JSX structure; it can&#8217;t assess runtime behavior, dynamic state, or the actual accessibility tree.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"v-layer-3-runtime-testing\">Layer 3: Runtime Testing<\/h2>\n\n\n\n<p>We can add another layer on top of static analysis: actual browser testing. A tool like <a href=\"https:\/\/playwright.dev\/\">Playwright<\/a> can help here along with a plugin specifically for accessibility testing, <a href=\"https:\/\/www.npmjs.com\/package\/@axe-core\/playwright\">@axe-core\/playwright<\/a>.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"Bash\" data-shcb-language-slug=\"bash\"><span><code class=\"hljs language-bash\">npm install --save-dev jest-axe @axe-core\/playwright<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">Bash<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">bash<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Here&#8217;s an example test that mounts the actual components and tests things in a browser.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> { render, screen, waitFor } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">'@testing-library\/react'<\/span>;\n<span class=\"hljs-keyword\">import<\/span> userEvent <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">'@testing-library\/user-event'<\/span>;\n<span class=\"hljs-keyword\">import<\/span> { axe, toHaveNoViolations } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">'jest-axe'<\/span>;\n<span class=\"hljs-keyword\">import<\/span> { SettingsSidebar } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">'.\/SettingsSidebar'<\/span>;\n\nexpect.extend(toHaveNoViolations);\n\ndescribe(<span class=\"hljs-string\">'SettingsSidebar'<\/span>, () =&gt; {\n  it(<span class=\"hljs-string\">'has no axe violations in collapsed state'<\/span>, <span class=\"hljs-keyword\">async<\/span> () =&gt; {\n    <span class=\"hljs-keyword\">const<\/span> { container } = render(<span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">SettingsSidebar<\/span> \/&gt;<\/span><\/span>);\n    expect(<span class=\"hljs-keyword\">await<\/span> axe(container)).toHaveNoViolations();\n  });\n\n  it(<span class=\"hljs-string\">'has no axe violations in expanded state'<\/span>, <span class=\"hljs-keyword\">async<\/span> () =&gt; {\n    <span class=\"hljs-keyword\">const<\/span> { container } = render(<span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">SettingsSidebar<\/span> \/&gt;<\/span><\/span>);\n    <span class=\"hljs-keyword\">await<\/span> userEvent.setup().click(\n      screen.getByRole(<span class=\"hljs-string\">'button'<\/span>, { <span class=\"hljs-attr\">name<\/span>: <span class=\"hljs-regexp\">\/account\/i<\/span> })\n    );\n\n    <span class=\"hljs-comment\">\/\/ axe evaluates the DOM at a point in time. Scanning during<\/span>\n    <span class=\"hljs-comment\">\/\/ a React re-render or CSS transition can produce results <\/span>\n    <span class=\"hljs-comment\">\/\/ against intermediate DOM states. Wait for it to settle.<\/span>\n    <span class=\"hljs-keyword\">await<\/span> waitFor(<span class=\"hljs-keyword\">async<\/span> () =&gt; {\n      expect(<span class=\"hljs-keyword\">await<\/span> axe(container)).toHaveNoViolations();\n    });\n  });\n\n  it(<span class=\"hljs-string\">'supports full keyboard navigation'<\/span>, <span class=\"hljs-keyword\">async<\/span> () =&gt; {\n    render(<span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">SettingsSidebar<\/span> \/&gt;<\/span><\/span>);\n    <span class=\"hljs-keyword\">const<\/span> user = userEvent.setup();\n\n    <span class=\"hljs-keyword\">await<\/span> user.tab();\n    expect(\n      screen.getByRole(<span class=\"hljs-string\">'button'<\/span>, { <span class=\"hljs-attr\">name<\/span>: <span class=\"hljs-regexp\">\/account\/i<\/span> })\n    ).toHaveFocus();\n\n    <span class=\"hljs-keyword\">await<\/span> user.keyboard(<span class=\"hljs-string\">'{Enter}'<\/span>);\n    expect(\n      screen.getByRole(<span class=\"hljs-string\">'button'<\/span>, { <span class=\"hljs-attr\">name<\/span>: <span class=\"hljs-regexp\">\/account\/i<\/span> })\n    ).toHaveAttribute(<span class=\"hljs-string\">'aria-expanded'<\/span>, <span class=\"hljs-string\">'true'<\/span>);\n\n    <span class=\"hljs-keyword\">await<\/span> user.tab();\n    expect(\n      screen.getByRole(<span class=\"hljs-string\">'link'<\/span>, { <span class=\"hljs-attr\">name<\/span>: <span class=\"hljs-regexp\">\/profile\/i<\/span> })\n    ).toHaveFocus();\n  });\n\n  it(<span class=\"hljs-string\">'exposes correct roles and states'<\/span>, () =&gt; {\n    render(<span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">SettingsSidebar<\/span> \/&gt;<\/span><\/span>);\n\n    expect(\n      screen.getByRole(<span class=\"hljs-string\">'navigation'<\/span>, { <span class=\"hljs-attr\">name<\/span>: <span class=\"hljs-regexp\">\/settings\/i<\/span> })\n    ).toBeInTheDocument();\n\n    expect(\n      screen.getByRole(<span class=\"hljs-string\">'button'<\/span>, { <span class=\"hljs-attr\">name<\/span>: <span class=\"hljs-regexp\">\/account\/i<\/span> })\n    ).toHaveAttribute(<span class=\"hljs-string\">'aria-expanded'<\/span>, <span class=\"hljs-string\">'false'<\/span>);\n  });\n});<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>These tests query by&nbsp;<strong>role<\/strong>&nbsp;and&nbsp;<strong>accessible name<\/strong>. This is not a stylistic choice. If&nbsp;<code>getByRole('button', { name: \/account\/i })<\/code>&nbsp;fails, a screen reader can&#8217;t find that element either. The testing Library&#8217;s query API is itself an accessibility assertion.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What axe-core Catches and What It Doesn&#8217;t<\/h3>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>It cannot assess whether your labels are&nbsp;<em>meaningful;<\/em>&nbsp;it checks that an&nbsp;<code>aria-label<\/code>&nbsp;exists, not that &#8220;click here&#8221; is useful. It cannot evaluate whether focus moves to the&nbsp;<em>right place<\/em>&nbsp;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.<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"vi-layer-4-ci-integration\">Layer 4: CI Integration<\/h2>\n\n\n\n<p>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).<\/p>\n\n\n\n<p><a href=\"https:\/\/docs.github.com\/en\/actions\">GitHub Actions<\/a> isn&#8217;t the only option, but it&#8217;s very popular because GitHub itself is. Here is an example <a href=\"https:\/\/docs.github.com\/en\/actions\/concepts\/workflows-and-actions\/workflows\">workflow<\/a> that runs our static-analysis linting and also includes a new browser test that loads the actual page and clicks things.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"YAML\" data-shcb-language-slug=\"yaml\"><span><code class=\"hljs language-yaml\"><span class=\"hljs-attr\">name:<\/span> <span class=\"hljs-string\">Accessibility<\/span> <span class=\"hljs-string\">Checks<\/span>\n<span class=\"hljs-attr\">on:<\/span> <span class=\"hljs-string\">&#91;push,<\/span> <span class=\"hljs-string\">pull_request]<\/span>\n\n<span class=\"hljs-attr\">jobs:<\/span>\n  <span class=\"hljs-attr\">lint:<\/span>\n    <span class=\"hljs-attr\">runs-on:<\/span> <span class=\"hljs-string\">ubuntu-latest<\/span>\n    <span class=\"hljs-attr\">steps:<\/span>\n      <span class=\"hljs-bullet\">-<\/span> <span class=\"hljs-attr\">uses:<\/span> <span class=\"hljs-string\">actions\/checkout@v4<\/span>\n      <span class=\"hljs-bullet\">-<\/span> <span class=\"hljs-attr\">uses:<\/span> <span class=\"hljs-string\">actions\/setup-node@v4<\/span>\n        <span class=\"hljs-attr\">with:<\/span> <span class=\"hljs-string\">{<\/span> <span class=\"hljs-attr\">node-version:<\/span> <span class=\"hljs-string\">'20'<\/span> <span class=\"hljs-string\">}<\/span>\n      <span class=\"hljs-bullet\">-<\/span> <span class=\"hljs-attr\">run:<\/span> <span class=\"hljs-string\">npm<\/span> <span class=\"hljs-string\">ci<\/span>\n      <span class=\"hljs-bullet\">-<\/span> <span class=\"hljs-attr\">name:<\/span> <span class=\"hljs-string\">ESLint<\/span> <span class=\"hljs-string\">a11y<\/span> <span class=\"hljs-string\">rules<\/span>\n        <span class=\"hljs-attr\">run:<\/span> <span class=\"hljs-string\">npx<\/span> <span class=\"hljs-string\">eslint<\/span> <span class=\"hljs-string\">'src\/**\/*.{ts,tsx}'<\/span> <span class=\"hljs-string\">--max-warnings<\/span> <span class=\"hljs-number\">0<\/span>\n\n  <span class=\"hljs-attr\">test:<\/span>\n    <span class=\"hljs-attr\">runs-on:<\/span> <span class=\"hljs-string\">ubuntu-latest<\/span>\n    <span class=\"hljs-attr\">steps:<\/span>\n      <span class=\"hljs-bullet\">-<\/span> <span class=\"hljs-attr\">uses:<\/span> <span class=\"hljs-string\">actions\/checkout@v4<\/span>\n      <span class=\"hljs-bullet\">-<\/span> <span class=\"hljs-attr\">uses:<\/span> <span class=\"hljs-string\">actions\/setup-node@v4<\/span>\n        <span class=\"hljs-attr\">with:<\/span> <span class=\"hljs-string\">{<\/span> <span class=\"hljs-attr\">node-version:<\/span> <span class=\"hljs-string\">'20'<\/span> <span class=\"hljs-string\">}<\/span>\n      <span class=\"hljs-bullet\">-<\/span> <span class=\"hljs-attr\">run:<\/span> <span class=\"hljs-string\">npm<\/span> <span class=\"hljs-string\">ci<\/span>\n      <span class=\"hljs-bullet\">-<\/span> <span class=\"hljs-attr\">name:<\/span> <span class=\"hljs-string\">Component<\/span> <span class=\"hljs-string\">accessibility<\/span> <span class=\"hljs-string\">tests<\/span>\n        <span class=\"hljs-attr\">run:<\/span> <span class=\"hljs-string\">npx<\/span> <span class=\"hljs-string\">jest<\/span> <span class=\"hljs-string\">--testPathPattern='\\.test\\.'<\/span>\n\n  <span class=\"hljs-attr\">e2e:<\/span>\n    <span class=\"hljs-attr\">runs-on:<\/span> <span class=\"hljs-string\">ubuntu-latest<\/span>\n    <span class=\"hljs-attr\">steps:<\/span>\n      <span class=\"hljs-bullet\">-<\/span> <span class=\"hljs-attr\">uses:<\/span> <span class=\"hljs-string\">actions\/checkout@v4<\/span>\n      <span class=\"hljs-bullet\">-<\/span> <span class=\"hljs-attr\">uses:<\/span> <span class=\"hljs-string\">actions\/setup-node@v4<\/span>\n        <span class=\"hljs-attr\">with:<\/span> <span class=\"hljs-string\">{<\/span> <span class=\"hljs-attr\">node-version:<\/span> <span class=\"hljs-string\">'20'<\/span> <span class=\"hljs-string\">}<\/span>\n      <span class=\"hljs-bullet\">-<\/span> <span class=\"hljs-attr\">run:<\/span> <span class=\"hljs-string\">npm<\/span> <span class=\"hljs-string\">ci<\/span>\n      <span class=\"hljs-bullet\">-<\/span> <span class=\"hljs-attr\">name:<\/span> <span class=\"hljs-string\">Playwright<\/span> <span class=\"hljs-string\">axe<\/span> <span class=\"hljs-string\">audit<\/span>\n        <span class=\"hljs-attr\">run:<\/span> <span class=\"hljs-string\">npx<\/span> <span class=\"hljs-string\">playwright<\/span> <span class=\"hljs-string\">test<\/span> <span class=\"hljs-string\">--grep<\/span> <span class=\"hljs-string\">@a11y<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">YAML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">yaml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>And here&#8217;s an End-to-End test example (i.e. see how it visits actual URLs).<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> { test, expect } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">'@playwright\/test'<\/span>;\n<span class=\"hljs-keyword\">import<\/span> AxeBuilder <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">'@axe-core\/playwright'<\/span>;\n\ntest(<span class=\"hljs-string\">'settings page passes axe audit @a11y'<\/span>, <span class=\"hljs-keyword\">async<\/span> ({ page }) =&gt; {\n  <span class=\"hljs-keyword\">await<\/span> page.goto(<span class=\"hljs-string\">'\/settings'<\/span>);\n  <span class=\"hljs-keyword\">await<\/span> page.waitForLoadState(<span class=\"hljs-string\">'networkidle'<\/span>);\n\n  <span class=\"hljs-keyword\">const<\/span> results = <span class=\"hljs-keyword\">await<\/span> <span class=\"hljs-keyword\">new<\/span> AxeBuilder({ page })\n    .withTags(&#91;<span class=\"hljs-string\">'wcag2a'<\/span>, <span class=\"hljs-string\">'wcag2aa'<\/span>, <span class=\"hljs-string\">'wcag21a'<\/span>, <span class=\"hljs-string\">'wcag21aa'<\/span>])\n    .analyze();\n  expect(results.violations).toEqual(&#91;]);\n});\n\ntest(<span class=\"hljs-string\">'sidebar keyboard navigation @a11y'<\/span>, <span class=\"hljs-keyword\">async<\/span> ({ page }) =&gt; {\n  <span class=\"hljs-keyword\">await<\/span> page.goto(<span class=\"hljs-string\">'\/settings'<\/span>);\n  <span class=\"hljs-keyword\">await<\/span> page.keyboard.press(<span class=\"hljs-string\">'Tab'<\/span>);\n\n  <span class=\"hljs-comment\">\/\/ Playwright locators are lazy-evaluated \u2014 :focus resolves to<\/span>\n  <span class=\"hljs-comment\">\/\/ whatever element has focus at assertion time, not capture time.<\/span>\n  <span class=\"hljs-keyword\">const<\/span> focused = page.locator(<span class=\"hljs-string\">':focus'<\/span>);\n  <span class=\"hljs-keyword\">await<\/span> expect(focused).toHaveRole(<span class=\"hljs-string\">'button'<\/span>);\n  <span class=\"hljs-keyword\">await<\/span> expect(focused).toHaveAttribute(<span class=\"hljs-string\">'aria-expanded'<\/span>, <span class=\"hljs-string\">'false'<\/span>);\n\n  <span class=\"hljs-keyword\">await<\/span> page.keyboard.press(<span class=\"hljs-string\">'Space'<\/span>);\n  <span class=\"hljs-keyword\">await<\/span> expect(focused).toHaveAttribute(<span class=\"hljs-string\">'aria-expanded'<\/span>, <span class=\"hljs-string\">'true'<\/span>);\n\n  <span class=\"hljs-keyword\">await<\/span> page.keyboard.press(<span class=\"hljs-string\">'Tab'<\/span>);\n  <span class=\"hljs-keyword\">await<\/span> expect(page.locator(<span class=\"hljs-string\">':focus'<\/span>)).toHaveRole(<span class=\"hljs-string\">'link'<\/span>);\n});<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h2 class=\"wp-block-heading\" id=\"vii-layer-5-accessible-component-abstractions\">Layer 5: Accessible Component Abstractions<\/h2>\n\n\n\n<p>The deepest solution is architectural. Instead of relying on every prompt to produce correct primitives, use libraries that encode accessibility into their API contracts.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/headlessui.com\/\">Headless UI<\/a>&nbsp;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&#8217;t cover, you&#8217;re on your own.&nbsp;<\/li>\n\n\n\n<li><a href=\"https:\/\/www.radix-ui.com\/\">Radix UI<\/a>&nbsp;covers roughly thirty unstyled primitives with a composable component API the pragmatic middle ground for most projects.&nbsp;<\/li>\n\n\n\n<li><a href=\"https:\/\/react-spectrum.adobe.com\/react-aria\/\">React Aria<\/a>&nbsp;from Adobe provides over forty hooks covering virtually every ARIA pattern, plus internationalization and virtual scrolling. It&#8217;s the right choice for teams building a design system. It&#8217;s overkill for adding an accessible dropdown to a CRUD app. The choice between them matters less than the decision to use&nbsp;<em>one of them<\/em>&nbsp;instead of letting AI build interactive primitives from&nbsp;<code>&lt;div&gt;<\/code>&nbsp;elements.<\/li>\n<\/ul>\n\n\n\n<p>Here&#8217;s an example of using Headless UI (bringing Tailwind for styling).<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> { Disclosure } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">'@headlessui\/react'<\/span>;\n\n<span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">SettingsSidebar<\/span>(<span class=\"hljs-params\"><\/span>) <\/span>{\n  <span class=\"hljs-keyword\">return<\/span> (\n    <span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">nav<\/span> <span class=\"hljs-attr\">aria-label<\/span>=<span class=\"hljs-string\">\"Settings\"<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"w-64 bg-gray-900 \n                                          text-white h-screen p-4\"<\/span>&gt;<\/span>\n      <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">h2<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"text-xl font-bold mb-6 px-2\"<\/span>&gt;<\/span>Settings<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">h2<\/span>&gt;<\/span>\n      \n      <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">ul<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"space-y-2 list-none\"<\/span> <span class=\"hljs-attr\">role<\/span>=<span class=\"hljs-string\">\"list\"<\/span>&gt;<\/span>\n        {sections.map(section =&gt; (\n          <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">li<\/span> <span class=\"hljs-attr\">key<\/span>=<span class=\"hljs-string\">{section.id}<\/span>&gt;<\/span>\n            <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">Disclosure<\/span>&gt;<\/span>\n              {({ open }) =&gt; (\n                <span class=\"hljs-tag\">&lt;&gt;<\/span>\n                  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">Disclosure.Button<\/span>\n                    <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"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\"<\/span>\n                  &gt;<\/span>\n                    {section.label}\n                    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">ChevronIcon<\/span>\n                      <span class=\"hljs-attr\">aria-hidden<\/span>=<span class=\"hljs-string\">\"true\"<\/span>\n                      <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">{<\/span>`<span class=\"hljs-attr\">w-4<\/span> <span class=\"hljs-attr\">h-4<\/span> \n                                 <span class=\"hljs-attr\">motion-safe:transition-transform<\/span>\n                                 <span class=\"hljs-attr\">motion-safe:duration-200<\/span> ${\n                                   <span class=\"hljs-attr\">open<\/span> ? '<span class=\"hljs-attr\">rotate-180<\/span>' <span class=\"hljs-attr\">:<\/span> ''\n                                 }`}\n                    \/&gt;<\/span>\n                  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">Disclosure.Button<\/span>&gt;<\/span>\n\n                  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">Disclosure.Panel<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"ml-4 mt-1 space-y-1\"<\/span>&gt;<\/span>\n                    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">ul<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"list-none\"<\/span> <span class=\"hljs-attr\">role<\/span>=<span class=\"hljs-string\">\"list\"<\/span>&gt;<\/span>\n                      {section.links.map(link =&gt; (\n                        <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">li<\/span> <span class=\"hljs-attr\">key<\/span>=<span class=\"hljs-string\">{link.href}<\/span>&gt;<\/span>\n                          <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">a<\/span>\n                            <span class=\"hljs-attr\">href<\/span>=<span class=\"hljs-string\">{link.href}<\/span>\n                            <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"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\"<\/span>\n                          &gt;<\/span>\n                            {link.label}\n                          <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">a<\/span>&gt;<\/span>\n                        <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">li<\/span>&gt;<\/span>\n                      ))}\n                    <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">ul<\/span>&gt;<\/span>\n                  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">Disclosure.Panel<\/span>&gt;<\/span>\n                <span class=\"hljs-tag\">&lt;\/&gt;<\/span>\n              )}\n            <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">Disclosure<\/span>&gt;<\/span>\n          <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">li<\/span>&gt;<\/span>\n        ))}\n      <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">ul<\/span>&gt;<\/span><\/span>\n    &lt;<span class=\"hljs-regexp\">\/nav&gt;\n  );\n}<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>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&#8217;s job shrinks to visual composition. The accessibility is structural. There can be advantages to <strong>decoupling the semantic layer from the visual layer.<\/strong>&nbsp;We let battle-tested libraries own the semantics and let AI own the styling.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"viii-common-ai-failure-patterns\">Common AI Failure Patterns<\/h2>\n\n\n\n<p>Three additional patterns beyond the sidebar merit attention because they involve more complex accessibility requirements.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The Invisible Modal<\/h3>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\">{isOpen &amp;&amp; (\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"fixed inset-0 z-50 flex items-center \n                  justify-center bg-black\/50\"<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"rounded-xl bg-white p-6 shadow-xl\"<\/span>&gt;<\/span>\n      <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"text-lg font-bold\"<\/span>&gt;<\/span>Confirm<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n      <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"mt-2\"<\/span>&gt;<\/span>Are you sure?<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n      <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"mt-4 flex gap-2\"<\/span>&gt;<\/span>\n        <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"cursor-pointer rounded bg-red-500 px-4 py-2 \n                        text-white\"<\/span> <span class=\"hljs-attr\">onClick<\/span>=<span class=\"hljs-string\">{onConfirm}<\/span>&gt;<\/span>Yes<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n        <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"cursor-pointer rounded bg-gray-300 px-4 py-2\"<\/span> \n             <span class=\"hljs-attr\">onClick<\/span>=<span class=\"hljs-string\">{onClose}<\/span>&gt;<\/span>No<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n      <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n)}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Issues:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>No&nbsp;<code>role=\"dialog\"<\/code><\/li>\n\n\n\n<li>No&nbsp;<code>aria-modal<\/code><\/li>\n\n\n\n<li>No label<\/li>\n\n\n\n<li>No focus trap<\/li>\n\n\n\n<li>No Escape handler<\/li>\n\n\n\n<li>No focus restoration<\/li>\n<\/ul>\n\n\n\n<p>The native&nbsp;<code>&lt;dialog&gt;<\/code>&nbsp;element with&nbsp;<code>showModal()<\/code>&nbsp;provides focus trapping, Escape to close,&nbsp;<code>aria-modal<\/code>, and backdrop handling for free. Better:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">dialog<\/span>\n  <span class=\"hljs-attr\">ref<\/span>=<span class=\"hljs-string\">{dialogRef}<\/span>\n  <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"rounded-xl bg-white p-6 shadow-xl backdrop:bg-black\/50\"<\/span>\n  <span class=\"hljs-attr\">aria-labelledby<\/span>=<span class=\"hljs-string\">\"dialog-title\"<\/span>\n  <span class=\"hljs-attr\">onClose<\/span>=<span class=\"hljs-string\">{onClose}<\/span>\n&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">h2<\/span> <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">\"dialog-title\"<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"text-lg font-bold\"<\/span>&gt;<\/span>Confirm<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">h2<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">p<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"mt-2\"<\/span>&gt;<\/span>Are you sure?<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">p<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"mt-4 flex gap-2\"<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">button<\/span> <span class=\"hljs-attr\">type<\/span>=<span class=\"hljs-string\">\"button\"<\/span>\n            <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"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\"<\/span>\n            <span class=\"hljs-attr\">onClick<\/span>=<span class=\"hljs-string\">{onConfirm}<\/span>&gt;<\/span>Yes<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">button<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">button<\/span> <span class=\"hljs-attr\">type<\/span>=<span class=\"hljs-string\">\"button\"<\/span>\n            <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"rounded bg-gray-300 px-4 py-2 \n                       hover:bg-gray-400 focus-visible:ring-2 \n                       focus-visible:ring-gray-500\"<\/span>\n            <span class=\"hljs-attr\">onClick<\/span>=<span class=\"hljs-string\">{onClose}<\/span>&gt;<\/span>No<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">button<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">dialog<\/span>&gt;<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>One caveat: native&nbsp;<code>&lt;dialog&gt;<\/code>&nbsp;has mostly excellent support in modern browsers, but there are still edge cases, particularly around focus restoration in older WebKit versions and inconsistent&nbsp;aria-modal&nbsp;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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The Icon-Only Button<\/h3>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"cursor-pointer rounded-full p-2 hover:bg-gray-100\"<\/span>\n     <span class=\"hljs-attr\">onClick<\/span>=<span class=\"hljs-string\">{onClose}<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">svg<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"h-5 w-5\"<\/span> <span class=\"hljs-attr\">viewBox<\/span>=<span class=\"hljs-string\">\"0 0 24 24\"<\/span> <span class=\"hljs-attr\">fill<\/span>=<span class=\"hljs-string\">\"none\"<\/span> \n       <span class=\"hljs-attr\">stroke<\/span>=<span class=\"hljs-string\">\"currentColor\"<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">path<\/span> <span class=\"hljs-attr\">strokeLinecap<\/span>=<span class=\"hljs-string\">\"round\"<\/span> <span class=\"hljs-attr\">strokeLinejoin<\/span>=<span class=\"hljs-string\">\"round\"<\/span> \n          <span class=\"hljs-attr\">strokeWidth<\/span>=<span class=\"hljs-string\">{2}<\/span> <span class=\"hljs-attr\">d<\/span>=<span class=\"hljs-string\">\"M6 18L18 6M6 6l12 12\"<\/span> \/&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">svg<\/span>&gt;<\/span>\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Issues:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The <code>role<\/code> is&nbsp;<code>generic<\/code> instead of <code>button<\/code><\/li>\n\n\n\n<li>Entirely empty name &#8211; invisible to assistive technology<\/li>\n\n\n\n<li>Unfocusable<\/li>\n<\/ul>\n\n\n\n<p>Better to use the correct native elements and attributes to help with naming:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-13\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">button<\/span> <span class=\"hljs-attr\">type<\/span>=<span class=\"hljs-string\">\"button\"<\/span>\n        <span class=\"hljs-attr\">aria-label<\/span>=<span class=\"hljs-string\">\"Close dialog\"<\/span>\n        <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"rounded-full p-2 hover:bg-gray-100 \n                   focus-visible:ring-2 focus-visible:ring-blue-500\"<\/span>\n        <span class=\"hljs-attr\">onClick<\/span>=<span class=\"hljs-string\">{onClose}<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">svg<\/span> <span class=\"hljs-attr\">aria-hidden<\/span>=<span class=\"hljs-string\">\"true\"<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"h-5 w-5\"<\/span> <span class=\"hljs-attr\">viewBox<\/span>=<span class=\"hljs-string\">\"0 0 24 24\"<\/span> \n       <span class=\"hljs-attr\">fill<\/span>=<span class=\"hljs-string\">\"none\"<\/span> <span class=\"hljs-attr\">stroke<\/span>=<span class=\"hljs-string\">\"currentColor\"<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">path<\/span> <span class=\"hljs-attr\">strokeLinecap<\/span>=<span class=\"hljs-string\">\"round\"<\/span> <span class=\"hljs-attr\">strokeLinejoin<\/span>=<span class=\"hljs-string\">\"round\"<\/span> \n          <span class=\"hljs-attr\">strokeWidth<\/span>=<span class=\"hljs-string\">{2}<\/span> <span class=\"hljs-attr\">d<\/span>=<span class=\"hljs-string\">\"M6 18L18 6M6 6l12 12\"<\/span> \/&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">svg<\/span>&gt;<\/span>\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">button<\/span>&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-13\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h3 class=\"wp-block-heading\">The Custom Select<\/h3>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-14\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"relative\"<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"cursor-pointer rounded border px-3 py-2\"<\/span> \n       <span class=\"hljs-attr\">onClick<\/span>=<span class=\"hljs-string\">{()<\/span> =&gt;<\/span> setIsOpen(!isOpen)}&gt;\n    {selected || 'Choose an option'}\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n  {isOpen &amp;&amp; (\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"absolute mt-1 w-full rounded border bg-white \n                    shadow-lg\"<\/span>&gt;<\/span>\n      {options.map(opt =&gt; (\n        <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">key<\/span>=<span class=\"hljs-string\">{opt}<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"cursor-pointer px-3 py-2 \n                                  hover:bg-blue-50\"<\/span>\n             <span class=\"hljs-attr\">onClick<\/span>=<span class=\"hljs-string\">{()<\/span> =&gt;<\/span> { setSelected(opt); setIsOpen(false); }}&gt;\n          {opt}\n        <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n      ))}\n    <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n  )}\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-14\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Issues:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>No <code>listbox<\/code> role<\/li>\n\n\n\n<li>No&nbsp;<code>aria-expanded<\/code> attribute<\/li>\n\n\n\n<li>No&nbsp;<code>aria-selected<\/code> attribute<\/li>\n\n\n\n<li>No&nbsp;<code>aria-activedescendant<\/code> attribute<\/li>\n\n\n\n<li>No keyboard navigation<\/li>\n<\/ul>\n\n\n\n<p>A correct implementation requires ~150 lines of keyboard management. My suggestion is to use a headless library:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-15\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> { Listbox } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">'@headlessui\/react'<\/span>;\n\n<span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">Listbox<\/span> <span class=\"hljs-attr\">value<\/span>=<span class=\"hljs-string\">{selected}<\/span> <span class=\"hljs-attr\">onChange<\/span>=<span class=\"hljs-string\">{setSelected}<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">Listbox.Label<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"block text-sm font-medium text-gray-700\"<\/span>&gt;<\/span>\n    Choose an option\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">Listbox.Label<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"relative mt-1\"<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">Listbox.Button<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"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\"<\/span>&gt;<\/span>\n      {selected || 'Select...'}\n    <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">Listbox.Button<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">Listbox.Options<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"absolute mt-1 w-full rounded border \n                                bg-white shadow-lg\"<\/span>&gt;<\/span>\n      {options.map(opt =&gt; (\n        <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">Listbox.Option<\/span>\n          <span class=\"hljs-attr\">key<\/span>=<span class=\"hljs-string\">{opt}<\/span>\n          <span class=\"hljs-attr\">value<\/span>=<span class=\"hljs-string\">{opt}<\/span>\n          <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">{({<\/span> <span class=\"hljs-attr\">active<\/span> }) =&gt;<\/span>\n            `cursor-pointer px-3 py-2 ${\n              active ? 'bg-blue-50 text-blue-900' : ''\n            }`\n          }\n        &gt;\n          {opt}\n        <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">Listbox.Option<\/span>&gt;<\/span>\n      ))}\n    <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">Listbox.Options<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">Listbox<\/span>&gt;<\/span><\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-15\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h3 class=\"wp-block-heading\">Color Contrast Failures<\/h3>\n\n\n\n<p>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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Motion Sensitivity Failures<\/h3>\n\n\n\n<p>Some of those code examples above use &nbsp;<a href=\"https:\/\/tailwindcss.com\/docs\/animation#supporting-reduced-motion\"><code>motion-safe<\/code> classes (from Tailwind)<\/a>&nbsp;on the chevron rotation, but don&#8217;t use them on the&nbsp;<code>transition-colors<\/code>&nbsp;for buttons and links. The distinction:&nbsp;<code>prefers-reduced-motion<\/code>&nbsp;media queries best address spatial movement, transforms, position changes, and scaling. Simple color transitions don&#8217;t involve spatial movement and are generally safe. If your component includes sliding panels, expanding animations, or transform-based transitions, you should use <code>prefers-reduced-motion<\/code>&nbsp;media queries (or the special Tailwind classes that help) to reduce or remove that movement.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"ix-costs-and-limits\">Costs and Limits<\/h2>\n\n\n\n<p>Adding accessibility constraints to AI-generated components adds <strong>3-8 minutes<\/strong> per component:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Workspace config setup (once)<\/li>\n\n\n\n<li>ARIA review<\/li>\n\n\n\n<li>Keyboard verification<\/li>\n\n\n\n<li>Then: Iteration when the constraints are ignored<\/li>\n<\/ul>\n\n\n\n<p>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 <strong>45-90 minutes<\/strong> per component.<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>Some AI tools are actively building accessibility into their pipelines. v0&#8217;s approach, generating Radix-based components by default, is the strongest example: it solves the problem architecturally by never generating raw&nbsp;<code>&lt;div&gt;<\/code>&nbsp;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.<\/p>\n\n\n\n<p>The two highest-leverage interventions are&nbsp;<code>eslint-plugin-jsx-a11y<\/code>&nbsp;set to&nbsp;<code>error<\/code>&nbsp;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.<\/p>\n\n\n\n<p>The accessibility tree reflects whatever DOM you give it. Make it good.<\/p>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>It doesn&#8217;t mean you can&#8217;t get AI to help with accessible code, you&#8217;ve just got to know what you&#8217;re doing.<\/p>\n","protected":false},"author":43,"featured_media":9295,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"categories":[1],"tags":[49,104,62],"class_list":["post-9221","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-accessibility","tag-ai","tag-react"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/poor-accessibility.jpg?fit=2000%2C1200&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9221","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/users\/43"}],"replies":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/comments?post=9221"}],"version-history":[{"count":10,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9221\/revisions"}],"predecessor-version":[{"id":9297,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9221\/revisions\/9297"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media\/9295"}],"wp:attachment":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media?parent=9221"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/categories?post=9221"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/tags?post=9221"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}