[{"data":1,"prerenderedAt":2429},["ShallowReactive",2],{"site-header-nav":3,"page-\u002Ftesting-and-automating-accessibility\u002F":314,"content-navigation":2277},[4,84,88,216],{"title":5,"path":6,"stem":7,"children":8},"Core Accessibility Principles For Modern Frameworks","\u002Fcore-accessibility-principles-for-modern-frameworks","core-accessibility-principles-for-modern-frameworks",[9,12,18,24,36,48,66,78],{"title":10,"path":6,"stem":11},"Core Accessibility Principles for Modern Frameworks","core-accessibility-principles-for-modern-frameworks\u002Findex",{"title":13,"path":14,"stem":15,"children":16},"Accessible Color Contrast & Theming","\u002Fcore-accessibility-principles-for-modern-frameworks\u002Faccessible-color-contrast-theming","core-accessibility-principles-for-modern-frameworks\u002Faccessible-color-contrast-theming\u002Findex",[17],{"title":13,"path":14,"stem":15},{"title":19,"path":20,"stem":21,"children":22},"Accessible Form Validation & Error States","\u002Fcore-accessibility-principles-for-modern-frameworks\u002Faccessible-form-validation-error-states","core-accessibility-principles-for-modern-frameworks\u002Faccessible-form-validation-error-states\u002Findex",[23],{"title":19,"path":20,"stem":21},{"title":25,"path":26,"stem":27,"children":28},"Focus Management Strategies for SPAs","\u002Fcore-accessibility-principles-for-modern-frameworks\u002Ffocus-management-strategies-for-spas","core-accessibility-principles-for-modern-frameworks\u002Ffocus-management-strategies-for-spas\u002Findex",[29,30],{"title":25,"path":26,"stem":27},{"title":31,"path":32,"stem":33,"children":34},"Handling Focus Restoration After Dynamic Route Changes","\u002Fcore-accessibility-principles-for-modern-frameworks\u002Ffocus-management-strategies-for-spas\u002Fhandling-focus-restoration-after-dynamic-route-changes","core-accessibility-principles-for-modern-frameworks\u002Ffocus-management-strategies-for-spas\u002Fhandling-focus-restoration-after-dynamic-route-changes\u002Findex",[35],{"title":31,"path":32,"stem":33},{"title":37,"path":38,"stem":39,"children":40},"Keyboard Navigation Patterns for Modals","\u002Fcore-accessibility-principles-for-modern-frameworks\u002Fkeyboard-navigation-patterns-for-modals","core-accessibility-principles-for-modern-frameworks\u002Fkeyboard-navigation-patterns-for-modals\u002Findex",[41,42],{"title":37,"path":38,"stem":39},{"title":43,"path":44,"stem":45,"children":46},"Building Accessible Dropdowns Without External UI Kits","\u002Fcore-accessibility-principles-for-modern-frameworks\u002Fkeyboard-navigation-patterns-for-modals\u002Fbuilding-accessible-dropdowns-without-external-ui-kits","core-accessibility-principles-for-modern-frameworks\u002Fkeyboard-navigation-patterns-for-modals\u002Fbuilding-accessible-dropdowns-without-external-ui-kits\u002Findex",[47],{"title":43,"path":44,"stem":45},{"title":49,"path":50,"stem":51,"children":52},"Reduced Motion & Animation Accessibility","\u002Fcore-accessibility-principles-for-modern-frameworks\u002Freduced-motion-and-animation-accessibility","core-accessibility-principles-for-modern-frameworks\u002Freduced-motion-and-animation-accessibility\u002Findex",[53,54,60],{"title":49,"path":50,"stem":51},{"title":55,"path":56,"stem":57,"children":58},"Accessible Loading Skeletons and Spinners","\u002Fcore-accessibility-principles-for-modern-frameworks\u002Freduced-motion-and-animation-accessibility\u002Faccessible-loading-skeletons-and-spinners","core-accessibility-principles-for-modern-frameworks\u002Freduced-motion-and-animation-accessibility\u002Faccessible-loading-skeletons-and-spinners\u002Findex",[59],{"title":55,"path":56,"stem":57},{"title":61,"path":62,"stem":63,"children":64},"Respecting prefers-reduced-motion in React and CSS","\u002Fcore-accessibility-principles-for-modern-frameworks\u002Freduced-motion-and-animation-accessibility\u002Frespecting-prefers-reduced-motion-in-react-and-css","core-accessibility-principles-for-modern-frameworks\u002Freduced-motion-and-animation-accessibility\u002Frespecting-prefers-reduced-motion-in-react-and-css\u002Findex",[65],{"title":61,"path":62,"stem":63},{"title":67,"path":68,"stem":69,"children":70},"Screen Reader Compatibility Testing","\u002Fcore-accessibility-principles-for-modern-frameworks\u002Fscreen-reader-compatibility-testing","core-accessibility-principles-for-modern-frameworks\u002Fscreen-reader-compatibility-testing\u002Findex",[71,72],{"title":67,"path":68,"stem":69},{"title":73,"path":74,"stem":75,"children":76},"Testing ARIA Live Regions with Jest and Testing Library","\u002Fcore-accessibility-principles-for-modern-frameworks\u002Fscreen-reader-compatibility-testing\u002Ftesting-aria-live-regions-with-jest-and-testing-library","core-accessibility-principles-for-modern-frameworks\u002Fscreen-reader-compatibility-testing\u002Ftesting-aria-live-regions-with-jest-and-testing-library\u002Findex",[77],{"title":73,"path":74,"stem":75},{"title":79,"path":80,"stem":81,"children":82},"Semantic HTML vs ARIA in Component Trees","\u002Fcore-accessibility-principles-for-modern-frameworks\u002Fsemantic-html-vs-aria-in-component-trees","core-accessibility-principles-for-modern-frameworks\u002Fsemantic-html-vs-aria-in-component-trees\u002Findex",[83],{"title":79,"path":80,"stem":81},{"title":85,"path":86,"stem":87},"Modern Framework Accessibility","\u002F","index",{"title":89,"path":90,"stem":91,"children":92},"React Nextjs Accessibility Patterns","\u002Freact-nextjs-accessibility-patterns","react-nextjs-accessibility-patterns",[93,96,108,132,156,162,180,204],{"title":94,"path":90,"stem":95},"React & Next.js Accessibility Patterns","react-nextjs-accessibility-patterns\u002Findex",{"title":97,"path":98,"stem":99,"children":100},"Accessible Component Libraries in React","\u002Freact-nextjs-accessibility-patterns\u002Faccessible-component-libraries-in-react","react-nextjs-accessibility-patterns\u002Faccessible-component-libraries-in-react\u002Findex",[101,102],{"title":97,"path":98,"stem":99},{"title":103,"path":104,"stem":105,"children":106},"Building Accessible Tabs in React Without Radix UI","\u002Freact-nextjs-accessibility-patterns\u002Faccessible-component-libraries-in-react\u002Fbuilding-accessible-tabs-in-react-without-radix-ui","react-nextjs-accessibility-patterns\u002Faccessible-component-libraries-in-react\u002Fbuilding-accessible-tabs-in-react-without-radix-ui\u002Findex",[107],{"title":103,"path":104,"stem":105},{"title":109,"path":110,"stem":111,"children":112},"Accessible Data Tables & Grids in React","\u002Freact-nextjs-accessibility-patterns\u002Faccessible-data-tables-and-grids","react-nextjs-accessibility-patterns\u002Faccessible-data-tables-and-grids\u002Findex",[113,114,120,126],{"title":109,"path":110,"stem":111},{"title":115,"path":116,"stem":117,"children":118},"Accessible Pagination for React Data Tables","\u002Freact-nextjs-accessibility-patterns\u002Faccessible-data-tables-and-grids\u002Faccessible-pagination-for-react-data-tables","react-nextjs-accessibility-patterns\u002Faccessible-data-tables-and-grids\u002Faccessible-pagination-for-react-data-tables\u002Findex",[119],{"title":115,"path":116,"stem":117},{"title":121,"path":122,"stem":123,"children":124},"Building a Sortable Accessible Data Table in React","\u002Freact-nextjs-accessibility-patterns\u002Faccessible-data-tables-and-grids\u002Fbuilding-a-sortable-accessible-data-table-in-react","react-nextjs-accessibility-patterns\u002Faccessible-data-tables-and-grids\u002Fbuilding-a-sortable-accessible-data-table-in-react\u002Findex",[125],{"title":121,"path":122,"stem":123},{"title":127,"path":128,"stem":129,"children":130},"Virtualizing Long Lists Accessibly in React","\u002Freact-nextjs-accessibility-patterns\u002Faccessible-data-tables-and-grids\u002Fvirtualizing-long-lists-accessibly-in-react","react-nextjs-accessibility-patterns\u002Faccessible-data-tables-and-grids\u002Fvirtualizing-long-lists-accessibly-in-react\u002Findex",[131],{"title":127,"path":128,"stem":129},{"title":133,"path":134,"stem":135,"children":136},"Dynamic Content & State Announcements","\u002Freact-nextjs-accessibility-patterns\u002Fdynamic-content-state-announcements","react-nextjs-accessibility-patterns\u002Fdynamic-content-state-announcements\u002Findex",[137,138,144,150],{"title":133,"path":134,"stem":135},{"title":139,"path":140,"stem":141,"children":142},"Accessible Toast Notifications in React","\u002Freact-nextjs-accessibility-patterns\u002Fdynamic-content-state-announcements\u002Faccessible-toast-notifications-in-react","react-nextjs-accessibility-patterns\u002Fdynamic-content-state-announcements\u002Faccessible-toast-notifications-in-react\u002Findex",[143],{"title":139,"path":140,"stem":141},{"title":145,"path":146,"stem":147,"children":148},"Announcing Client-Side Route Changes in React","\u002Freact-nextjs-accessibility-patterns\u002Fdynamic-content-state-announcements\u002Fannouncing-client-side-route-changes-in-react","react-nextjs-accessibility-patterns\u002Fdynamic-content-state-announcements\u002Fannouncing-client-side-route-changes-in-react\u002Findex",[149],{"title":145,"path":146,"stem":147},{"title":151,"path":152,"stem":153,"children":154},"React Context for Accessibility Preferences","\u002Freact-nextjs-accessibility-patterns\u002Fdynamic-content-state-announcements\u002Freact-context-for-global-accessibility-preferences","react-nextjs-accessibility-patterns\u002Fdynamic-content-state-announcements\u002Freact-context-for-global-accessibility-preferences\u002Findex",[155],{"title":151,"path":152,"stem":153},{"title":157,"path":158,"stem":159,"children":160},"Form Handling with React Hook Form & A11y","\u002Freact-nextjs-accessibility-patterns\u002Fform-handling-with-react-hook-form-a11y","react-nextjs-accessibility-patterns\u002Fform-handling-with-react-hook-form-a11y\u002Findex",[161],{"title":157,"path":158,"stem":159},{"title":163,"path":164,"stem":165,"children":166},"Next.js App Router Accessibility","\u002Freact-nextjs-accessibility-patterns\u002Fnextjs-app-router-a11y","react-nextjs-accessibility-patterns\u002Fnextjs-app-router-a11y\u002Findex",[167,168,174],{"title":163,"path":164,"stem":165},{"title":169,"path":170,"stem":171,"children":172},"Skip Links in Next.js App Router","\u002Freact-nextjs-accessibility-patterns\u002Fnextjs-app-router-a11y\u002Fimplementing-skip-links-in-nextjs-app-router","react-nextjs-accessibility-patterns\u002Fnextjs-app-router-a11y\u002Fimplementing-skip-links-in-nextjs-app-router\u002Findex",[173],{"title":169,"path":170,"stem":171},{"title":175,"path":176,"stem":177,"children":178},"Next.js Dynamic Imports & Keyboard Navigation","\u002Freact-nextjs-accessibility-patterns\u002Fnextjs-app-router-a11y\u002Fnextjs-dynamic-imports-and-keyboard-navigation","react-nextjs-accessibility-patterns\u002Fnextjs-app-router-a11y\u002Fnextjs-dynamic-imports-and-keyboard-navigation\u002Findex",[179],{"title":175,"path":176,"stem":177},{"title":181,"path":182,"stem":183,"children":184},"React Hooks for Accessibility","\u002Freact-nextjs-accessibility-patterns\u002Freact-hooks-for-accessibility","react-nextjs-accessibility-patterns\u002Freact-hooks-for-accessibility\u002Findex",[185,186,192,198],{"title":181,"path":182,"stem":183},{"title":187,"path":188,"stem":189,"children":190},"Building a useAnnouncer Hook for Live Regions","\u002Freact-nextjs-accessibility-patterns\u002Freact-hooks-for-accessibility\u002Fbuilding-a-useannouncer-hook-for-live-regions","react-nextjs-accessibility-patterns\u002Freact-hooks-for-accessibility\u002Fbuilding-a-useannouncer-hook-for-live-regions\u002Findex",[191],{"title":187,"path":188,"stem":189},{"title":193,"path":194,"stem":195,"children":196},"Fixing Focus Trap Issues in React Portals","\u002Freact-nextjs-accessibility-patterns\u002Freact-hooks-for-accessibility\u002Ffixing-focus-trap-issues-in-react-portals","react-nextjs-accessibility-patterns\u002Freact-hooks-for-accessibility\u002Ffixing-focus-trap-issues-in-react-portals\u002Findex",[197],{"title":193,"path":194,"stem":195},{"title":199,"path":200,"stem":201,"children":202},"Making React useEffect Accessible for Screen Readers","\u002Freact-nextjs-accessibility-patterns\u002Freact-hooks-for-accessibility\u002Fmaking-react-useeffect-accessible-for-screen-readers","react-nextjs-accessibility-patterns\u002Freact-hooks-for-accessibility\u002Fmaking-react-useeffect-accessible-for-screen-readers\u002Findex",[203],{"title":199,"path":200,"stem":201},{"title":205,"path":206,"stem":207,"children":208},"Server Components & Client-Side Interactivity","\u002Freact-nextjs-accessibility-patterns\u002Fserver-components-client-side-interactivity","react-nextjs-accessibility-patterns\u002Fserver-components-client-side-interactivity\u002Findex",[209,210],{"title":205,"path":206,"stem":207},{"title":211,"path":212,"stem":213,"children":214},"Accessible Modals in Next.js 14 Server Components","\u002Freact-nextjs-accessibility-patterns\u002Fserver-components-client-side-interactivity\u002Fhandling-accessible-modals-in-nextjs-14-server-components","react-nextjs-accessibility-patterns\u002Fserver-components-client-side-interactivity\u002Fhandling-accessible-modals-in-nextjs-14-server-components\u002Findex",[215],{"title":211,"path":212,"stem":213},{"title":217,"path":218,"stem":219,"children":220},"Testing And Automating Accessibility","\u002Ftesting-and-automating-accessibility","testing-and-automating-accessibility",[221,224,242,260,278,296],{"title":222,"path":218,"stem":223},"Testing & Automating Accessibility","testing-and-automating-accessibility\u002Findex",{"title":225,"path":226,"stem":227,"children":228},"Accessibility Audits with Lighthouse","\u002Ftesting-and-automating-accessibility\u002Faccessibility-audits-with-lighthouse","testing-and-automating-accessibility\u002Faccessibility-audits-with-lighthouse\u002Findex",[229,230,236],{"title":225,"path":226,"stem":227},{"title":231,"path":232,"stem":233,"children":234},"Interpreting Lighthouse Accessibility Scores","\u002Ftesting-and-automating-accessibility\u002Faccessibility-audits-with-lighthouse\u002Finterpreting-lighthouse-accessibility-scores","testing-and-automating-accessibility\u002Faccessibility-audits-with-lighthouse\u002Finterpreting-lighthouse-accessibility-scores\u002Findex",[235],{"title":231,"path":232,"stem":233},{"title":237,"path":238,"stem":239,"children":240},"Setting Lighthouse CI Accessibility Budgets","\u002Ftesting-and-automating-accessibility\u002Faccessibility-audits-with-lighthouse\u002Fsetting-lighthouse-ci-accessibility-budgets","testing-and-automating-accessibility\u002Faccessibility-audits-with-lighthouse\u002Fsetting-lighthouse-ci-accessibility-budgets\u002Findex",[241],{"title":237,"path":238,"stem":239},{"title":243,"path":244,"stem":245,"children":246},"Automated Accessibility Testing with axe-core","\u002Ftesting-and-automating-accessibility\u002Fautomated-accessibility-testing-with-axe-core","testing-and-automating-accessibility\u002Fautomated-accessibility-testing-with-axe-core\u002Findex",[247,248,254],{"title":243,"path":244,"stem":245},{"title":249,"path":250,"stem":251,"children":252},"Catching Color Contrast Failures with axe-core","\u002Ftesting-and-automating-accessibility\u002Fautomated-accessibility-testing-with-axe-core\u002Fcatching-color-contrast-failures-with-axe-core","testing-and-automating-accessibility\u002Fautomated-accessibility-testing-with-axe-core\u002Fcatching-color-contrast-failures-with-axe-core\u002Findex",[253],{"title":249,"path":250,"stem":251},{"title":255,"path":256,"stem":257,"children":258},"Writing Custom axe-core Rules","\u002Ftesting-and-automating-accessibility\u002Fautomated-accessibility-testing-with-axe-core\u002Fwriting-custom-axe-core-rules","testing-and-automating-accessibility\u002Fautomated-accessibility-testing-with-axe-core\u002Fwriting-custom-axe-core-rules\u002Findex",[259],{"title":255,"path":256,"stem":257},{"title":261,"path":262,"stem":263,"children":264},"Component Testing with jest-axe","\u002Ftesting-and-automating-accessibility\u002Fcomponent-testing-with-jest-axe","testing-and-automating-accessibility\u002Fcomponent-testing-with-jest-axe\u002Findex",[265,266,272],{"title":261,"path":262,"stem":263},{"title":267,"path":268,"stem":269,"children":270},"Debugging jest-axe Violations in CI","\u002Ftesting-and-automating-accessibility\u002Fcomponent-testing-with-jest-axe\u002Fdebugging-jest-axe-violations-in-ci","testing-and-automating-accessibility\u002Fcomponent-testing-with-jest-axe\u002Fdebugging-jest-axe-violations-in-ci\u002Findex",[271],{"title":267,"path":268,"stem":269},{"title":273,"path":274,"stem":275,"children":276},"Testing React Components with jest-axe","\u002Ftesting-and-automating-accessibility\u002Fcomponent-testing-with-jest-axe\u002Ftesting-react-components-with-jest-axe","testing-and-automating-accessibility\u002Fcomponent-testing-with-jest-axe\u002Ftesting-react-components-with-jest-axe\u002Findex",[277],{"title":273,"path":274,"stem":275},{"title":279,"path":280,"stem":281,"children":282},"End-to-End Accessibility Testing with Playwright","\u002Ftesting-and-automating-accessibility\u002Fend-to-end-accessibility-testing-with-playwright","testing-and-automating-accessibility\u002Fend-to-end-accessibility-testing-with-playwright\u002Findex",[283,284,290],{"title":279,"path":280,"stem":281},{"title":285,"path":286,"stem":287,"children":288},"Asserting Focus Order in Playwright","\u002Ftesting-and-automating-accessibility\u002Fend-to-end-accessibility-testing-with-playwright\u002Fasserting-focus-order-in-playwright","testing-and-automating-accessibility\u002Fend-to-end-accessibility-testing-with-playwright\u002Fasserting-focus-order-in-playwright\u002Findex",[289],{"title":285,"path":286,"stem":287},{"title":291,"path":292,"stem":293,"children":294},"Keyboard Navigation Tests in Playwright","\u002Ftesting-and-automating-accessibility\u002Fend-to-end-accessibility-testing-with-playwright\u002Fkeyboard-navigation-tests-in-playwright","testing-and-automating-accessibility\u002Fend-to-end-accessibility-testing-with-playwright\u002Fkeyboard-navigation-tests-in-playwright\u002Findex",[295],{"title":291,"path":292,"stem":293},{"title":297,"path":298,"stem":299,"children":300},"Gating Accessibility in CI\u002FCD Pipelines","\u002Ftesting-and-automating-accessibility\u002Fgating-accessibility-in-ci-cd-pipelines","testing-and-automating-accessibility\u002Fgating-accessibility-in-ci-cd-pipelines\u002Findex",[301,302,308],{"title":297,"path":298,"stem":299},{"title":303,"path":304,"stem":305,"children":306},"Accessibility Regression Testing in GitHub Actions","\u002Ftesting-and-automating-accessibility\u002Fgating-accessibility-in-ci-cd-pipelines\u002Faccessibility-regression-testing-in-github-actions","testing-and-automating-accessibility\u002Fgating-accessibility-in-ci-cd-pipelines\u002Faccessibility-regression-testing-in-github-actions\u002Findex",[307],{"title":303,"path":304,"stem":305},{"title":309,"path":310,"stem":311,"children":312},"Failing Pull Requests on axe Violations","\u002Ftesting-and-automating-accessibility\u002Fgating-accessibility-in-ci-cd-pipelines\u002Ffailing-pull-requests-on-axe-violations","testing-and-automating-accessibility\u002Fgating-accessibility-in-ci-cd-pipelines\u002Ffailing-pull-requests-on-axe-violations\u002Findex",[313],{"title":309,"path":310,"stem":311},{"id":315,"title":222,"body":316,"date":2270,"description":2271,"extension":2272,"image":2270,"meta":2273,"modifiedAt":2270,"navigation":794,"noindex":2274,"path":218,"publishedAt":2270,"seo":2275,"stem":223,"updatedAt":2270,"__hash__":2276},"content\u002Ftesting-and-automating-accessibility\u002Findex.md",{"type":317,"value":318,"toc":2257},"minimark",[319,323,336,348,490,497,500,505,508,554,573,575,579,586,589,628,635,673,680,690,692,696,711,736,940,956,958,962,973,1157,1172,1182,1184,1188,1207,1212,1579,1587,1589,1593,1604,1724,1733,1735,1739,1746,2006,2023,2025,2029,2032,2086,2093,2095,2099,2131,2133,2137,2143,2153,2159,2168,2181,2199,2201,2205,2253],[320,321,222],"h1",{"id":322},"testing-automating-accessibility",[324,325,326,327,331,332,335],"p",{},"Accessibility regressions are silent. A refactored ",[328,329,330],"code",{},"\u003Cdiv>"," that used to be a ",[328,333,334],{},"\u003Cbutton>",", a modal that stopped trapping focus, a contrast token nudged two shades lighter — none of these throw a runtime error, none fail a type check, and most slip past code review because reviewers read diffs, not accessibility trees. By the time a real user on NVDA or VoiceOver reports the breakage, the regression has shipped, compounded, and entangled itself with three more releases. The only sustainable defense is a layered, automated test strategy that fails the build before broken markup reaches production.",[324,337,338,339,343,344,347],{},"This guide is for frontend and UI engineers who already understand WCAG fundamentals and now need a ",[340,341,342],"em",{},"workflow",": which tool runs at which layer, what each layer can and cannot catch, and how to wire it all into CI so a violation blocks a merge instead of generating a ticket. Automation is non-negotiable at scale — but automation alone certifies nothing. Tools like axe-core reliably detect a specific, machine-decidable subset of failures: missing form labels, invalid ARIA attribute values, duplicate IDs, color-contrast ratios. They cannot tell you whether a screen reader announces your custom combobox coherently, whether ",[328,345,346],{},"Escape"," actually closes your dropdown, or whether the focus order matches the visual reading order. Those require a human with a keyboard and a screen reader. The discipline is knowing exactly where the machine stops and the human begins, and building both into the same pipeline.",[349,350,357,358,357,362,357,357,366,357,376,357,357,384,357,394,357,400,357,357,405,357,409,357,413,357,357,417,357,424,357,429,357,357,433,357,437,357,442,357,357,447,357,454,357,357,471,357,477,357,482,357,486],"svg",{"role":351,"ariaLabelledBy":352,"viewBox":355,"style":356},"img",[353,354],"diagTitle","diagDesc","0 0 760 380","width:100%;height:auto;max-width:760px","\n  ",[359,360,361],"title",{"id":353},"The accessibility testing pyramid feeding a CI gate",[363,364,365],"desc",{"id":354},"A four-layer testing pyramid—static analysis at the base, then component tests with jest-axe, then end-to-end browser tests with Playwright, then Lighthouse audits at the apex—all flowing right into a CI gate that either passes the merge or blocks it. A wrap-around band labeled manual screen reader and keyboard testing surrounds every layer.",[367,368],"rect",{"style":369,"x":370,"y":370,"width":371,"height":372,"rx":373,"fill":374,"stroke":375},"stroke-width:2;stroke-dasharray:6 5","14","520","352","16","none","var(--border)",[377,378,383],"text",{"style":379,"x":380,"y":381,"fill":382},"font-size:13px;text-anchor:middle","274","36","var(--muted)","Manual NVDA \u002F VoiceOver + keyboard testing wraps every layer",[367,385],{"style":386,"x":387,"y":388,"width":389,"height":390,"rx":391,"fill":392,"stroke":393},"stroke-width:2","44","290","460","56","6","var(--primary-soft)","currentColor",[377,395,399],{"style":396,"x":380,"y":397,"fill":398},"font-size:15px;text-anchor:middle","316","var(--text)","Static analysis — eslint-plugin-jsx-a11y, types",[377,401,404],{"style":402,"x":380,"y":403,"fill":382},"font-size:12px;text-anchor:middle","334","fastest · runs on every save and pre-commit",[367,406],{"style":386,"x":407,"y":408,"width":372,"height":390,"rx":391,"fill":392,"stroke":393},"98","226",[377,410,412],{"style":396,"x":380,"y":411,"fill":398},"252","Component tests — jest-axe",[377,414,416],{"style":402,"x":380,"y":415,"fill":382},"270","rendered DOM, isolated, runs in JSDOM",[367,418],{"style":386,"x":419,"y":420,"width":421,"height":390,"rx":391,"fill":422,"stroke":423},"152","162","244","var(--primary)","var(--primary-strong)",[377,425,428],{"style":396,"x":380,"y":426,"fill":427},"188","var(--surface)","E2E — Playwright + axe",[377,430,432],{"style":402,"x":380,"y":431,"fill":392},"206","real browser, focus, routes",[367,434],{"style":386,"x":431,"y":407,"width":435,"height":436,"rx":391,"fill":423,"stroke":423},"136","52",[377,438,441],{"style":439,"x":380,"y":440,"fill":427},"font-size:14px;text-anchor:middle","122","Lighthouse",[377,443,446],{"style":444,"x":380,"y":445,"fill":392},"font-size:11px;text-anchor:middle","139","page-level budgets",[448,449],"line",{"style":450,"x1":451,"y1":452,"x2":453,"y2":452,"stroke":393},"stroke-width:2;marker-end:url(#arrow)","534","190","600",[455,456,457,458,357],"defs",{},"\n    ",[459,460,466,467,457],"marker",{"id":461,"markerWidth":462,"markerHeight":462,"refX":463,"refY":464,"orient":465},"arrow","10","8","3","auto","\n      ",[468,469],"path",{"d":470,"fill":393},"M0,0 L8,3 L0,6 Z",[367,472],{"style":386,"x":473,"y":474,"width":475,"height":476,"rx":463,"fill":427,"stroke":423},"606","150","138","80",[377,478,481],{"style":396,"x":479,"y":480,"fill":398},"675","180","CI gate",[377,483,485],{"style":402,"x":479,"y":484,"fill":382},"202","pass → merge",[377,487,489],{"style":402,"x":479,"y":488,"fill":382},"218","fail → block",[324,491,492,493,496],{},"The pyramid above is the mental model for the rest of this guide: cheap, fast checks run on the broad base and execute constantly; slower, higher-fidelity checks run less often nearer the apex; and the entire stack funnels into a single CI gate. Manual testing is not a layer ",[340,494,495],{},"in"," the pyramid — it is the band that wraps the whole thing, because no automated layer can replace a human listening to actual screen reader speech.",[498,499],"hr",{},[501,502,504],"h2",{"id":503},"what-youll-learn","What You'll Learn",[324,506,507],{},"This guide maps to five focused areas. Each handles one layer of the strategy in depth; read this page for how they fit together, then drill into whichever layer you're wiring up.",[509,510,511,522,530,538,546],"ul",{},[512,513,514,521],"li",{},[515,516,517],"strong",{},[518,519,243],"a",{"href":520},"\u002Ftesting-and-automating-accessibility\u002Fautomated-accessibility-testing-with-axe-core\u002F"," — the shared rules engine that powers nearly every other tool in this stack, how its rule set maps to WCAG, and how to configure, scope, and triage its results.",[512,523,524,529],{},[515,525,526],{},[518,527,261],{"href":528},"\u002Ftesting-and-automating-accessibility\u002Fcomponent-testing-with-jest-axe\u002F"," — running axe-core against rendered components in JSDOM so a broken label or invalid ARIA attribute fails a unit test the moment it's introduced.",[512,531,532,537],{},[515,533,534],{},[518,535,279],{"href":536},"\u002Ftesting-and-automating-accessibility\u002Fend-to-end-accessibility-testing-with-playwright\u002F"," — testing real keyboard flows, focus order, route transitions, and dynamic states in a live browser where JSDOM can't reach.",[512,539,540,545],{},[515,541,542],{},[518,543,225],{"href":544},"\u002Ftesting-and-automating-accessibility\u002Faccessibility-audits-with-lighthouse\u002F"," — page-level scoring and budgets that act as a coarse regression tripwire across whole routes, ideal for CI score thresholds.",[512,547,548,553],{},[515,549,550],{},[518,551,297],{"href":552},"\u002Ftesting-and-automating-accessibility\u002Fgating-accessibility-in-ci-cd-pipelines\u002F"," — turning all of the above into blocking checks so accessibility violations fail the build instead of becoming backlog.",[324,555,556,557,560,561,564,565,568,569,572],{},"These five layers complement, not replace, the foundations in ",[518,558,10],{"href":559},"\u002Fcore-accessibility-principles-for-modern-frameworks\u002F"," and the implementation patterns in ",[518,562,94],{"href":563},"\u002Freact-nextjs-accessibility-patterns\u002F",". Testing tells you ",[340,566,567],{},"whether"," an interface is accessible; those pillars tell you ",[340,570,571],{},"how"," to build it so it passes.",[498,574],{},[501,576,578],{"id":577},"what-automation-catches-and-the-half-it-cant","What Automation Catches — and the Half It Can't",[324,580,581,582,585],{},"The single most dangerous misconception in accessibility tooling is treating a clean axe scan or a Lighthouse score of 100 as proof of compliance. It is not. Industry analyses of axe-core and similar engines consistently put automated coverage at roughly 30–50% of WCAG success criteria by ",[340,583,584],{},"number of criteria",", and even within the criteria a tool partially checks, it only catches the machine-decidable failures. The remaining ~50–67% of issues require human judgment.",[324,587,588],{},"Automation reliably catches deterministic, structural failures:",[509,590,591,598,601,607,621],{},[512,592,593,594,597],{},"Missing or empty accessible names on form controls and buttons (",[328,595,596],{},"4.1.2 Name, Role, Value",").",[512,599,600],{},"Invalid ARIA: roles applied to elements that disallow them, required attributes missing, attribute values that aren't valid tokens.",[512,602,603,604,597],{},"Color contrast ratios below the threshold (",[328,605,606],{},"1.4.3 Contrast (Minimum)",[512,608,609,610,613,614,86,617,620],{},"Duplicate ",[328,611,612],{},"id"," values, broken ",[328,615,616],{},"aria-labelledby",[328,618,619],{},"aria-describedby"," references, document-language gaps.",[512,622,623,624,627],{},"Images without ",[328,625,626],{},"alt",", lists with invalid children, tables without headers.",[324,629,630,631,634],{},"Automation ",[515,632,633],{},"cannot"," decide the things that depend on meaning and interaction:",[509,636,637,648,654,663,666],{},[512,638,639,640,643,644,647],{},"Whether ",[328,641,642],{},"alt=\"image\""," is ",[340,645,646],{},"useful"," — it's present, so it passes, but it tells a screen reader user nothing.",[512,649,650,651,653],{},"Whether your custom widget's role and state actually produce coherent screen reader speech (",[328,652,596],{}," is only partly machine-checkable).",[512,655,656,657,659,660,597],{},"Whether every interaction is operable from the keyboard, including arrow-key navigation inside composite widgets and ",[328,658,346],{},"-to-close (",[328,661,662],{},"2.1.1 Keyboard",[512,664,665],{},"Whether the focus order matches the visual reading order, or whether focus is ever lost after a route change.",[512,667,668,669,672],{},"Whether an ",[328,670,671],{},"aria-live"," announcement actually fires at the right moment with the right politeness.",[324,674,675,676,679],{},"So the rule is blunt: ",[515,677,678],{},"automation is a regression net, not a certification."," It exists to catch the boring, repetitive, high-volume failures that humans miss in review — freeing your manual testing budget for the judgment calls no tool can make. Every layer in this guide is built on that premise.",[324,681,682,685,686,689],{},[515,683,684],{},"How to verify the gap is covered:"," run your automated suite, then pick one critical user flow per release and walk it manually with a keyboard only (no mouse) and with NVDA or VoiceOver running. If the automated suite is green but the manual walk surfaces a problem, that's exactly the ~50–67% your machine can't see — and a signal to add a Playwright assertion or a manual test-case to your release checklist. For a structured approach to the manual side, see ",[518,687,67],{"href":688},"\u002Fcore-accessibility-principles-for-modern-frameworks\u002Fscreen-reader-compatibility-testing\u002F",".",[498,691],{},[501,693,695],{"id":694},"axe-core-the-shared-engine-under-everything","axe-core: The Shared Engine Under Everything",[324,697,698,699,702,703,706,707,710],{},"Before looking at layers, understand the engine. ",[515,700,701],{},"axe-core"," is the open-source rules engine that powers jest-axe, the official Playwright integration (",[328,704,705],{},"@axe-core\u002Fplaywright","), the axe DevTools browser extension, and Lighthouse's accessibility audits. This is the most important architectural fact in the whole stack: you are running essentially the ",[340,708,709],{},"same rule set"," at every layer, just against different runtimes (JSDOM, a real Chromium page, a full Lighthouse audit). That consistency is a feature — a violation you fix to satisfy jest-axe stays fixed in Playwright and Lighthouse, because they share rule definitions.",[324,712,713,714,717,718,721,722,721,725,721,728,731,732,735],{},"axe-core groups rules by WCAG conformance level using ",[340,715,716],{},"tags"," (",[328,719,720],{},"wcag2a",", ",[328,723,724],{},"wcag2aa",[328,726,727],{},"wcag21a",[328,729,730],{},"wcag22aa",", plus ",[328,733,734],{},"best-practice","). You select which families run by tag, and you can disable individual rules where a known, documented exception applies.",[737,738,743],"pre",{"className":739,"code":740,"language":741,"meta":742,"style":742},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F a11y\u002Faxe-config.ts — one shared config consumed by jest-axe AND Playwright\n\u002F\u002F so every layer enforces an identical rule set. Drift between layers is a\n\u002F\u002F common source of \"passes in unit tests, fails in CI e2e\" confusion.\nimport type { RunOptions } from 'axe-core';\n\nexport const axeRunOptions: RunOptions = {\n  \u002F\u002F Run only the conformance levels we actually commit to (WCAG 2.2 AA).\n  runOnly: {\n    type: 'tag',\n    values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'],\n  },\n  rules: {\n    \u002F\u002F Contrast is meaningless in JSDOM (no layout\u002Fpaint), so component tests\n    \u002F\u002F disable it and let Playwright\u002FLighthouse catch it in a real browser.\n    'color-contrast': { enabled: true },\n    \u002F\u002F Example of a documented, deliberate exception — keep these rare and reviewed.\n    \u002F\u002F 'region': { enabled: false },\n  },\n};\n","ts","",[328,744,745,753,759,765,789,796,822,828,834,846,878,884,890,896,902,917,923,929,934],{"__ignoreMap":742},[746,747,749],"span",{"class":448,"line":748},1,[746,750,752],{"class":751},"sJ8bj","\u002F\u002F a11y\u002Faxe-config.ts — one shared config consumed by jest-axe AND Playwright\n",[746,754,756],{"class":448,"line":755},2,[746,757,758],{"class":751},"\u002F\u002F so every layer enforces an identical rule set. Drift between layers is a\n",[746,760,762],{"class":448,"line":761},3,[746,763,764],{"class":751},"\u002F\u002F common source of \"passes in unit tests, fails in CI e2e\" confusion.\n",[746,766,768,772,775,779,782,786],{"class":448,"line":767},4,[746,769,771],{"class":770},"szBVR","import",[746,773,774],{"class":770}," type",[746,776,778],{"class":777},"sVt8B"," { RunOptions } ",[746,780,781],{"class":770},"from",[746,783,785],{"class":784},"sZZnC"," 'axe-core'",[746,787,788],{"class":777},";\n",[746,790,792],{"class":448,"line":791},5,[746,793,795],{"emptyLinePlaceholder":794},true,"\n",[746,797,799,802,805,809,812,816,819],{"class":448,"line":798},6,[746,800,801],{"class":770},"export",[746,803,804],{"class":770}," const",[746,806,808],{"class":807},"sj4cs"," axeRunOptions",[746,810,811],{"class":770},":",[746,813,815],{"class":814},"sScJk"," RunOptions",[746,817,818],{"class":770}," =",[746,820,821],{"class":777}," {\n",[746,823,825],{"class":448,"line":824},7,[746,826,827],{"class":751},"  \u002F\u002F Run only the conformance levels we actually commit to (WCAG 2.2 AA).\n",[746,829,831],{"class":448,"line":830},8,[746,832,833],{"class":777},"  runOnly: {\n",[746,835,837,840,843],{"class":448,"line":836},9,[746,838,839],{"class":777},"    type: ",[746,841,842],{"class":784},"'tag'",[746,844,845],{"class":777},",\n",[746,847,849,852,855,857,860,862,865,867,870,872,875],{"class":448,"line":848},10,[746,850,851],{"class":777},"    values: [",[746,853,854],{"class":784},"'wcag2a'",[746,856,721],{"class":777},[746,858,859],{"class":784},"'wcag2aa'",[746,861,721],{"class":777},[746,863,864],{"class":784},"'wcag21a'",[746,866,721],{"class":777},[746,868,869],{"class":784},"'wcag21aa'",[746,871,721],{"class":777},[746,873,874],{"class":784},"'wcag22aa'",[746,876,877],{"class":777},"],\n",[746,879,881],{"class":448,"line":880},11,[746,882,883],{"class":777},"  },\n",[746,885,887],{"class":448,"line":886},12,[746,888,889],{"class":777},"  rules: {\n",[746,891,893],{"class":448,"line":892},13,[746,894,895],{"class":751},"    \u002F\u002F Contrast is meaningless in JSDOM (no layout\u002Fpaint), so component tests\n",[746,897,899],{"class":448,"line":898},14,[746,900,901],{"class":751},"    \u002F\u002F disable it and let Playwright\u002FLighthouse catch it in a real browser.\n",[746,903,905,908,911,914],{"class":448,"line":904},15,[746,906,907],{"class":784},"    'color-contrast'",[746,909,910],{"class":777},": { enabled: ",[746,912,913],{"class":807},"true",[746,915,916],{"class":777}," },\n",[746,918,920],{"class":448,"line":919},16,[746,921,922],{"class":751},"    \u002F\u002F Example of a documented, deliberate exception — keep these rare and reviewed.\n",[746,924,926],{"class":448,"line":925},17,[746,927,928],{"class":751},"    \u002F\u002F 'region': { enabled: false },\n",[746,930,932],{"class":448,"line":931},18,[746,933,883],{"class":777},[746,935,937],{"class":448,"line":936},19,[746,938,939],{"class":777},"};\n",[324,941,942,945,946,949,950,953,954,689],{},[515,943,944],{},"How to verify:"," after defining a shared config, deliberately introduce one violation (e.g. remove a ",[328,947,948],{},"\u003Clabel>",") and confirm it is reported with the ",[340,951,952],{},"same rule id"," in both your component test output and your Playwright run. Matching rule ids across layers proves the engine is genuinely shared and your tags line up. Full configuration depth — selectors, exclusions, custom rules, and result triage — lives in ",[518,955,243],{"href":520},[498,957],{},[501,959,961],{"id":960},"the-component-layer-jest-axe","The Component Layer: jest-axe",[324,963,964,965,968,969,972],{},"The base of the practical pyramid (above static linting) is component-level testing. ",[515,966,967],{},"jest-axe"," runs axe-core against the DOM your component renders inside JSDOM, then exposes a ",[328,970,971],{},"toHaveNoViolations()"," matcher. This is the cheapest place to catch a structural regression because it runs with your existing unit tests, in milliseconds, on every change — long before a browser is ever spun up.",[737,974,978],{"className":975,"code":976,"language":977,"meta":742,"style":742},"language-tsx shiki shiki-themes github-light github-dark","\u002F\u002F AccountMenu.test.tsx\nimport { render } from '@testing-library\u002Freact';\nimport { axe, toHaveNoViolations } from 'jest-axe';\nimport { AccountMenu } from '.\u002FAccountMenu';\n\nexpect.extend(toHaveNoViolations);\n\ntest('AccountMenu has no axe violations in its default state', async () => {\n  const { container } = render(\u003CAccountMenu user={{ name: 'Ada' }} \u002F>);\n  \u002F\u002F Scans the rendered subtree for machine-decidable WCAG failures:\n  \u002F\u002F missing names, invalid ARIA, broken references, bad roles.\n  const results = await axe(container);\n  expect(results).toHaveNoViolations();\n});\n","tsx",[328,979,980,985,999,1013,1027,1031,1042,1046,1070,1110,1115,1120,1138,1152],{"__ignoreMap":742},[746,981,982],{"class":448,"line":748},[746,983,984],{"class":751},"\u002F\u002F AccountMenu.test.tsx\n",[746,986,987,989,992,994,997],{"class":448,"line":755},[746,988,771],{"class":770},[746,990,991],{"class":777}," { render } ",[746,993,781],{"class":770},[746,995,996],{"class":784}," '@testing-library\u002Freact'",[746,998,788],{"class":777},[746,1000,1001,1003,1006,1008,1011],{"class":448,"line":761},[746,1002,771],{"class":770},[746,1004,1005],{"class":777}," { axe, toHaveNoViolations } ",[746,1007,781],{"class":770},[746,1009,1010],{"class":784}," 'jest-axe'",[746,1012,788],{"class":777},[746,1014,1015,1017,1020,1022,1025],{"class":448,"line":767},[746,1016,771],{"class":770},[746,1018,1019],{"class":777}," { AccountMenu } ",[746,1021,781],{"class":770},[746,1023,1024],{"class":784}," '.\u002FAccountMenu'",[746,1026,788],{"class":777},[746,1028,1029],{"class":448,"line":791},[746,1030,795],{"emptyLinePlaceholder":794},[746,1032,1033,1036,1039],{"class":448,"line":798},[746,1034,1035],{"class":777},"expect.",[746,1037,1038],{"class":814},"extend",[746,1040,1041],{"class":777},"(toHaveNoViolations);\n",[746,1043,1044],{"class":448,"line":824},[746,1045,795],{"emptyLinePlaceholder":794},[746,1047,1048,1051,1054,1057,1059,1062,1065,1068],{"class":448,"line":830},[746,1049,1050],{"class":814},"test",[746,1052,1053],{"class":777},"(",[746,1055,1056],{"class":784},"'AccountMenu has no axe violations in its default state'",[746,1058,721],{"class":777},[746,1060,1061],{"class":770},"async",[746,1063,1064],{"class":777}," () ",[746,1066,1067],{"class":770},"=>",[746,1069,821],{"class":777},[746,1071,1072,1075,1078,1081,1084,1087,1090,1093,1096,1099,1101,1104,1107],{"class":448,"line":836},[746,1073,1074],{"class":770},"  const",[746,1076,1077],{"class":777}," { ",[746,1079,1080],{"class":807},"container",[746,1082,1083],{"class":777}," } ",[746,1085,1086],{"class":770},"=",[746,1088,1089],{"class":814}," render",[746,1091,1092],{"class":777},"(\u003C",[746,1094,1095],{"class":807},"AccountMenu",[746,1097,1098],{"class":814}," user",[746,1100,1086],{"class":770},[746,1102,1103],{"class":777},"{{ name: ",[746,1105,1106],{"class":784},"'Ada'",[746,1108,1109],{"class":777}," }} \u002F>);\n",[746,1111,1112],{"class":448,"line":848},[746,1113,1114],{"class":751},"  \u002F\u002F Scans the rendered subtree for machine-decidable WCAG failures:\n",[746,1116,1117],{"class":448,"line":880},[746,1118,1119],{"class":751},"  \u002F\u002F missing names, invalid ARIA, broken references, bad roles.\n",[746,1121,1122,1124,1127,1129,1132,1135],{"class":448,"line":886},[746,1123,1074],{"class":770},[746,1125,1126],{"class":807}," results",[746,1128,818],{"class":770},[746,1130,1131],{"class":770}," await",[746,1133,1134],{"class":814}," axe",[746,1136,1137],{"class":777},"(container);\n",[746,1139,1140,1143,1146,1149],{"class":448,"line":892},[746,1141,1142],{"class":814},"  expect",[746,1144,1145],{"class":777},"(results).",[746,1147,1148],{"class":814},"toHaveNoViolations",[746,1150,1151],{"class":777},"();\n",[746,1153,1154],{"class":448,"line":898},[746,1155,1156],{"class":777},"});\n",[324,1158,1159,1160,1163,1164,1167,1168,1171],{},"The critical limitation: JSDOM has no layout engine and no real focus model, so the component layer is blind to contrast, focus order, and anything that depends on actual rendering or interaction. It catches ",[340,1161,1162],{},"static structure"," superbly and ",[340,1165,1166],{},"behavior"," not at all. That's by design — keep these tests fast and let the e2e layer handle interaction. Render each meaningful ",[340,1169,1170],{},"state"," (open menu, error state, loading state), because each state is different markup with its own potential violations.",[324,1173,1174,1176,1177,1179,1180,689],{},[515,1175,944],{}," assert against multiple states, not just the initial render, and confirm a deliberately broken state (e.g. an error message not associated via ",[328,1178,619],{},") fails the test. The mechanics, matcher options, and CI-friendly reporting are covered in ",[518,1181,261],{"href":528},[498,1183],{},[501,1185,1187],{"id":1186},"the-e2e-layer-playwright-for-real-behavior","The E2E Layer: Playwright for Real Behavior",[324,1189,1190,1191,1194,1195,1198,1199,1202,1203,1206],{},"Static scans can confirm a button ",[340,1192,1193],{},"exists"," with an accessible name. Only a real browser can confirm that pressing ",[328,1196,1197],{},"Tab"," reaches it, that ",[328,1200,1201],{},"Enter"," activates it, that focus moves into the dialog it opens and returns afterward, and that a client-side route change doesn't strip focus to ",[328,1204,1205],{},"\u003Cbody>",". This is the e2e layer, and Playwright is the strongest tool for it because it drives a genuine Chromium\u002FFirefox\u002FWebKit page with a real focus model and real event dispatch.",[324,1208,1209,1211],{},[328,1210,705],{}," runs the same axe engine against the live page — now contrast and layout-dependent rules actually work — and Playwright's input APIs let you assert the behavioral criteria axe can't reach.",[737,1213,1215],{"className":739,"code":1214,"language":741,"meta":742,"style":742},"\u002F\u002F e2e\u002Fdialog.spec.ts\nimport { test, expect } from '@playwright\u002Ftest';\nimport AxeBuilder from '@axe-core\u002Fplaywright';\n\ntest('settings dialog: scan is clean and keyboard behavior is correct', async ({ page }) => {\n  await page.goto('\u002Fsettings');\n\n  \u002F\u002F Same axe engine, but in a real browser — contrast and layout rules now apply.\n  const scan = await new AxeBuilder({ page })\n    .withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])\n    .analyze();\n  expect(scan.violations).toEqual([]);\n\n  \u002F\u002F Behavior axe cannot check: keyboard operability (2.1.1) and focus management.\n  await page.getByRole('button', { name: 'Open settings' }).focus();\n  await page.keyboard.press('Enter');\n\n  const dialog = page.getByRole('dialog', { name: 'Settings' });\n  await expect(dialog).toBeVisible();\n  \u002F\u002F Focus must land inside the dialog, not on \u003Cbody>.\n  await expect(dialog.locator(':focus')).toBeVisible();\n\n  \u002F\u002F Escape must close it and focus must return to the trigger (2.4.3 Focus Order).\n  await page.keyboard.press('Escape');\n  await expect(dialog).toBeHidden();\n  await expect(page.getByRole('button', { name: 'Open settings' })).toBeFocused();\n});\n",[328,1216,1217,1222,1236,1250,1254,1281,1300,1304,1309,1329,1353,1362,1375,1379,1384,1412,1429,1433,1459,1474,1480,1505,1510,1516,1532,1546,1574],{"__ignoreMap":742},[746,1218,1219],{"class":448,"line":748},[746,1220,1221],{"class":751},"\u002F\u002F e2e\u002Fdialog.spec.ts\n",[746,1223,1224,1226,1229,1231,1234],{"class":448,"line":755},[746,1225,771],{"class":770},[746,1227,1228],{"class":777}," { test, expect } ",[746,1230,781],{"class":770},[746,1232,1233],{"class":784}," '@playwright\u002Ftest'",[746,1235,788],{"class":777},[746,1237,1238,1240,1243,1245,1248],{"class":448,"line":761},[746,1239,771],{"class":770},[746,1241,1242],{"class":777}," AxeBuilder ",[746,1244,781],{"class":770},[746,1246,1247],{"class":784}," '@axe-core\u002Fplaywright'",[746,1249,788],{"class":777},[746,1251,1252],{"class":448,"line":767},[746,1253,795],{"emptyLinePlaceholder":794},[746,1255,1256,1258,1260,1263,1265,1267,1270,1274,1277,1279],{"class":448,"line":791},[746,1257,1050],{"class":814},[746,1259,1053],{"class":777},[746,1261,1262],{"class":784},"'settings dialog: scan is clean and keyboard behavior is correct'",[746,1264,721],{"class":777},[746,1266,1061],{"class":770},[746,1268,1269],{"class":777}," ({ ",[746,1271,1273],{"class":1272},"s4XuR","page",[746,1275,1276],{"class":777}," }) ",[746,1278,1067],{"class":770},[746,1280,821],{"class":777},[746,1282,1283,1286,1289,1292,1294,1297],{"class":448,"line":798},[746,1284,1285],{"class":770},"  await",[746,1287,1288],{"class":777}," page.",[746,1290,1291],{"class":814},"goto",[746,1293,1053],{"class":777},[746,1295,1296],{"class":784},"'\u002Fsettings'",[746,1298,1299],{"class":777},");\n",[746,1301,1302],{"class":448,"line":824},[746,1303,795],{"emptyLinePlaceholder":794},[746,1305,1306],{"class":448,"line":830},[746,1307,1308],{"class":751},"  \u002F\u002F Same axe engine, but in a real browser — contrast and layout rules now apply.\n",[746,1310,1311,1313,1316,1318,1320,1323,1326],{"class":448,"line":836},[746,1312,1074],{"class":770},[746,1314,1315],{"class":807}," scan",[746,1317,818],{"class":770},[746,1319,1131],{"class":770},[746,1321,1322],{"class":770}," new",[746,1324,1325],{"class":814}," AxeBuilder",[746,1327,1328],{"class":777},"({ page })\n",[746,1330,1331,1334,1337,1340,1342,1344,1346,1348,1350],{"class":448,"line":848},[746,1332,1333],{"class":777},"    .",[746,1335,1336],{"class":814},"withTags",[746,1338,1339],{"class":777},"([",[746,1341,854],{"class":784},[746,1343,721],{"class":777},[746,1345,859],{"class":784},[746,1347,721],{"class":777},[746,1349,874],{"class":784},[746,1351,1352],{"class":777},"])\n",[746,1354,1355,1357,1360],{"class":448,"line":880},[746,1356,1333],{"class":777},[746,1358,1359],{"class":814},"analyze",[746,1361,1151],{"class":777},[746,1363,1364,1366,1369,1372],{"class":448,"line":886},[746,1365,1142],{"class":814},[746,1367,1368],{"class":777},"(scan.violations).",[746,1370,1371],{"class":814},"toEqual",[746,1373,1374],{"class":777},"([]);\n",[746,1376,1377],{"class":448,"line":892},[746,1378,795],{"emptyLinePlaceholder":794},[746,1380,1381],{"class":448,"line":898},[746,1382,1383],{"class":751},"  \u002F\u002F Behavior axe cannot check: keyboard operability (2.1.1) and focus management.\n",[746,1385,1386,1388,1390,1393,1395,1398,1401,1404,1407,1410],{"class":448,"line":904},[746,1387,1285],{"class":770},[746,1389,1288],{"class":777},[746,1391,1392],{"class":814},"getByRole",[746,1394,1053],{"class":777},[746,1396,1397],{"class":784},"'button'",[746,1399,1400],{"class":777},", { name: ",[746,1402,1403],{"class":784},"'Open settings'",[746,1405,1406],{"class":777}," }).",[746,1408,1409],{"class":814},"focus",[746,1411,1151],{"class":777},[746,1413,1414,1416,1419,1422,1424,1427],{"class":448,"line":919},[746,1415,1285],{"class":770},[746,1417,1418],{"class":777}," page.keyboard.",[746,1420,1421],{"class":814},"press",[746,1423,1053],{"class":777},[746,1425,1426],{"class":784},"'Enter'",[746,1428,1299],{"class":777},[746,1430,1431],{"class":448,"line":925},[746,1432,795],{"emptyLinePlaceholder":794},[746,1434,1435,1437,1440,1442,1444,1446,1448,1451,1453,1456],{"class":448,"line":931},[746,1436,1074],{"class":770},[746,1438,1439],{"class":807}," dialog",[746,1441,818],{"class":770},[746,1443,1288],{"class":777},[746,1445,1392],{"class":814},[746,1447,1053],{"class":777},[746,1449,1450],{"class":784},"'dialog'",[746,1452,1400],{"class":777},[746,1454,1455],{"class":784},"'Settings'",[746,1457,1458],{"class":777}," });\n",[746,1460,1461,1463,1466,1469,1472],{"class":448,"line":936},[746,1462,1285],{"class":770},[746,1464,1465],{"class":814}," expect",[746,1467,1468],{"class":777},"(dialog).",[746,1470,1471],{"class":814},"toBeVisible",[746,1473,1151],{"class":777},[746,1475,1477],{"class":448,"line":1476},20,[746,1478,1479],{"class":751},"  \u002F\u002F Focus must land inside the dialog, not on \u003Cbody>.\n",[746,1481,1483,1485,1487,1490,1493,1495,1498,1501,1503],{"class":448,"line":1482},21,[746,1484,1285],{"class":770},[746,1486,1465],{"class":814},[746,1488,1489],{"class":777},"(dialog.",[746,1491,1492],{"class":814},"locator",[746,1494,1053],{"class":777},[746,1496,1497],{"class":784},"':focus'",[746,1499,1500],{"class":777},")).",[746,1502,1471],{"class":814},[746,1504,1151],{"class":777},[746,1506,1508],{"class":448,"line":1507},22,[746,1509,795],{"emptyLinePlaceholder":794},[746,1511,1513],{"class":448,"line":1512},23,[746,1514,1515],{"class":751},"  \u002F\u002F Escape must close it and focus must return to the trigger (2.4.3 Focus Order).\n",[746,1517,1519,1521,1523,1525,1527,1530],{"class":448,"line":1518},24,[746,1520,1285],{"class":770},[746,1522,1418],{"class":777},[746,1524,1421],{"class":814},[746,1526,1053],{"class":777},[746,1528,1529],{"class":784},"'Escape'",[746,1531,1299],{"class":777},[746,1533,1535,1537,1539,1541,1544],{"class":448,"line":1534},25,[746,1536,1285],{"class":770},[746,1538,1465],{"class":814},[746,1540,1468],{"class":777},[746,1542,1543],{"class":814},"toBeHidden",[746,1545,1151],{"class":777},[746,1547,1549,1551,1553,1556,1558,1560,1562,1564,1566,1569,1572],{"class":448,"line":1548},26,[746,1550,1285],{"class":770},[746,1552,1465],{"class":814},[746,1554,1555],{"class":777},"(page.",[746,1557,1392],{"class":814},[746,1559,1053],{"class":777},[746,1561,1397],{"class":784},[746,1563,1400],{"class":777},[746,1565,1403],{"class":784},[746,1567,1568],{"class":777}," })).",[746,1570,1571],{"class":814},"toBeFocused",[746,1573,1151],{"class":777},[746,1575,1577],{"class":448,"line":1576},27,[746,1578,1156],{"class":777},[324,1580,1581,1582,1584,1585,689],{},"This is the only automated layer that can verify ",[328,1583,662],{}," and focus restoration in anything close to a realistic environment. It's slower and runs less often than component tests — typically on PRs and pre-merge — which is exactly why it sits higher on the pyramid. The full pattern set, including testing live-region announcements and route transitions, is in ",[518,1586,279],{"href":536},[498,1588],{},[501,1590,1592],{"id":1591},"the-audit-layer-lighthouse-and-budgets","The Audit Layer: Lighthouse and Budgets",[324,1594,1595,1596,1599,1600,1603],{},"Lighthouse runs an axe-core-based accessibility audit across an entire rendered page and produces a 0–100 score. It overlaps heavily with the e2e scan, so don't treat it as a separate source of truth for individual violations — its value is a different one: a ",[515,1597,1598],{},"coarse, page-level budget"," you can threshold in CI. A route that suddenly drops from 100 to 86 is a loud, cheap signal that ",[340,1601,1602],{},"something"," structural regressed across that whole page, even if no single component test was watching that exact element.",[737,1605,1609],{"className":1606,"code":1607,"language":1608,"meta":742,"style":742},"language-js shiki shiki-themes github-light github-dark","\u002F\u002F lighthouserc.js — Lighthouse CI asserts a per-route accessibility budget.\nmodule.exports = {\n  ci: {\n    collect: {\n      url: ['http:\u002F\u002Flocalhost:3000\u002F', 'http:\u002F\u002Flocalhost:3000\u002Fsettings'],\n      numberOfRuns: 3, \u002F\u002F average runs to reduce score flakiness\n    },\n    assert: {\n      assertions: {\n        \u002F\u002F Block the build if the accessibility category drops below budget.\n        'categories:accessibility': ['error', { minScore: 1 }],\n      },\n    },\n  },\n};\n","js",[328,1610,1611,1616,1630,1635,1640,1655,1667,1672,1677,1682,1687,1707,1712,1716,1720],{"__ignoreMap":742},[746,1612,1613],{"class":448,"line":748},[746,1614,1615],{"class":751},"\u002F\u002F lighthouserc.js — Lighthouse CI asserts a per-route accessibility budget.\n",[746,1617,1618,1621,1623,1626,1628],{"class":448,"line":755},[746,1619,1620],{"class":807},"module",[746,1622,689],{"class":777},[746,1624,1625],{"class":807},"exports",[746,1627,818],{"class":770},[746,1629,821],{"class":777},[746,1631,1632],{"class":448,"line":761},[746,1633,1634],{"class":777},"  ci: {\n",[746,1636,1637],{"class":448,"line":767},[746,1638,1639],{"class":777},"    collect: {\n",[746,1641,1642,1645,1648,1650,1653],{"class":448,"line":791},[746,1643,1644],{"class":777},"      url: [",[746,1646,1647],{"class":784},"'http:\u002F\u002Flocalhost:3000\u002F'",[746,1649,721],{"class":777},[746,1651,1652],{"class":784},"'http:\u002F\u002Flocalhost:3000\u002Fsettings'",[746,1654,877],{"class":777},[746,1656,1657,1660,1662,1664],{"class":448,"line":798},[746,1658,1659],{"class":777},"      numberOfRuns: ",[746,1661,464],{"class":807},[746,1663,721],{"class":777},[746,1665,1666],{"class":751},"\u002F\u002F average runs to reduce score flakiness\n",[746,1668,1669],{"class":448,"line":824},[746,1670,1671],{"class":777},"    },\n",[746,1673,1674],{"class":448,"line":830},[746,1675,1676],{"class":777},"    assert: {\n",[746,1678,1679],{"class":448,"line":836},[746,1680,1681],{"class":777},"      assertions: {\n",[746,1683,1684],{"class":448,"line":848},[746,1685,1686],{"class":751},"        \u002F\u002F Block the build if the accessibility category drops below budget.\n",[746,1688,1689,1692,1695,1698,1701,1704],{"class":448,"line":880},[746,1690,1691],{"class":784},"        'categories:accessibility'",[746,1693,1694],{"class":777},": [",[746,1696,1697],{"class":784},"'error'",[746,1699,1700],{"class":777},", { minScore: ",[746,1702,1703],{"class":807},"1",[746,1705,1706],{"class":777}," }],\n",[746,1708,1709],{"class":448,"line":886},[746,1710,1711],{"class":777},"      },\n",[746,1713,1714],{"class":448,"line":892},[746,1715,1671],{"class":777},[746,1717,1718],{"class":448,"line":898},[746,1719,883],{"class":777},[746,1721,1722],{"class":448,"line":904},[746,1723,939],{"class":777},[324,1725,1726,1727,1730,1731,689],{},"The trap is over-trusting the number: a Lighthouse score of 100 means \"no failures the audit can detect,\" which is the same partial coverage as any axe-based tool — it is ",[340,1728,1729],{},"not"," a compliance certificate. Use it as a tripwire across many routes, and rely on jest-axe and Playwright for granular, element-level enforcement. Budget strategy, scoring nuances, and how to combine page-level audits with component-level scans are covered in ",[518,1732,225],{"href":544},[498,1734],{},[501,1736,1738],{"id":1737},"the-ci-gate-failing-the-build-on-violations","The CI Gate: Failing the Build on Violations",[324,1740,1741,1742,1745],{},"A test that runs locally but doesn't block a merge is documentation, not enforcement. The entire point of the pyramid is to funnel into a CI gate that makes accessibility violations ",[340,1743,1744],{},"fail the build"," — the same severity as a failing type check or a broken unit test. Without a blocking gate, every layer above degrades into advisory noise that teams learn to ignore.",[737,1747,1751],{"className":1748,"code":1749,"language":1750,"meta":742,"style":742},"language-yaml shiki shiki-themes github-light github-dark","# .github\u002Fworkflows\u002Fa11y.yml\nname: accessibility\non: [pull_request]\n\njobs:\n  a11y:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v4\n      - uses: actions\u002Fsetup-node@v4\n        with: { node-version: 20, cache: npm }\n      - run: npm ci\n\n      # Layer 1 — static analysis: cheapest, fails first.\n      - run: npm run lint  # includes eslint-plugin-jsx-a11y\n\n      # Layer 2 — component tests with jest-axe.\n      - run: npm test -- --ci\n\n      # Layer 3 — e2e with Playwright + axe.\n      - run: npx playwright install --with-deps chromium\n      - run: npm run test:e2e\n\n      # Layer 4 — Lighthouse budget. A non-zero exit fails the whole job,\n      # which blocks the merge when the branch is protected.\n      - run: npm run build && npm run start &\n      - run: npx wait-on http:\u002F\u002Flocalhost:3000\n      - run: npx @lhci\u002Fcli autorun\n","yaml",[328,1752,1753,1758,1770,1783,1787,1795,1802,1812,1819,1832,1843,1872,1884,1888,1893,1907,1911,1916,1927,1931,1936,1947,1958,1962,1967,1972,1983,1994],{"__ignoreMap":742},[746,1754,1755],{"class":448,"line":748},[746,1756,1757],{"class":751},"# .github\u002Fworkflows\u002Fa11y.yml\n",[746,1759,1760,1764,1767],{"class":448,"line":755},[746,1761,1763],{"class":1762},"s9eBZ","name",[746,1765,1766],{"class":777},": ",[746,1768,1769],{"class":784},"accessibility\n",[746,1771,1772,1775,1777,1780],{"class":448,"line":761},[746,1773,1774],{"class":807},"on",[746,1776,1694],{"class":777},[746,1778,1779],{"class":784},"pull_request",[746,1781,1782],{"class":777},"]\n",[746,1784,1785],{"class":448,"line":767},[746,1786,795],{"emptyLinePlaceholder":794},[746,1788,1789,1792],{"class":448,"line":791},[746,1790,1791],{"class":1762},"jobs",[746,1793,1794],{"class":777},":\n",[746,1796,1797,1800],{"class":448,"line":798},[746,1798,1799],{"class":1762},"  a11y",[746,1801,1794],{"class":777},[746,1803,1804,1807,1809],{"class":448,"line":824},[746,1805,1806],{"class":1762},"    runs-on",[746,1808,1766],{"class":777},[746,1810,1811],{"class":784},"ubuntu-latest\n",[746,1813,1814,1817],{"class":448,"line":830},[746,1815,1816],{"class":1762},"    steps",[746,1818,1794],{"class":777},[746,1820,1821,1824,1827,1829],{"class":448,"line":836},[746,1822,1823],{"class":777},"      - ",[746,1825,1826],{"class":1762},"uses",[746,1828,1766],{"class":777},[746,1830,1831],{"class":784},"actions\u002Fcheckout@v4\n",[746,1833,1834,1836,1838,1840],{"class":448,"line":848},[746,1835,1823],{"class":777},[746,1837,1826],{"class":1762},[746,1839,1766],{"class":777},[746,1841,1842],{"class":784},"actions\u002Fsetup-node@v4\n",[746,1844,1845,1848,1851,1854,1856,1859,1861,1864,1866,1869],{"class":448,"line":880},[746,1846,1847],{"class":1762},"        with",[746,1849,1850],{"class":777},": { ",[746,1852,1853],{"class":1762},"node-version",[746,1855,1766],{"class":777},[746,1857,1858],{"class":807},"20",[746,1860,721],{"class":777},[746,1862,1863],{"class":1762},"cache",[746,1865,1766],{"class":777},[746,1867,1868],{"class":784},"npm",[746,1870,1871],{"class":777}," }\n",[746,1873,1874,1876,1879,1881],{"class":448,"line":886},[746,1875,1823],{"class":777},[746,1877,1878],{"class":1762},"run",[746,1880,1766],{"class":777},[746,1882,1883],{"class":784},"npm ci\n",[746,1885,1886],{"class":448,"line":892},[746,1887,795],{"emptyLinePlaceholder":794},[746,1889,1890],{"class":448,"line":898},[746,1891,1892],{"class":751},"      # Layer 1 — static analysis: cheapest, fails first.\n",[746,1894,1895,1897,1899,1901,1904],{"class":448,"line":904},[746,1896,1823],{"class":777},[746,1898,1878],{"class":1762},[746,1900,1766],{"class":777},[746,1902,1903],{"class":784},"npm run lint",[746,1905,1906],{"class":751},"  # includes eslint-plugin-jsx-a11y\n",[746,1908,1909],{"class":448,"line":919},[746,1910,795],{"emptyLinePlaceholder":794},[746,1912,1913],{"class":448,"line":925},[746,1914,1915],{"class":751},"      # Layer 2 — component tests with jest-axe.\n",[746,1917,1918,1920,1922,1924],{"class":448,"line":931},[746,1919,1823],{"class":777},[746,1921,1878],{"class":1762},[746,1923,1766],{"class":777},[746,1925,1926],{"class":784},"npm test -- --ci\n",[746,1928,1929],{"class":448,"line":936},[746,1930,795],{"emptyLinePlaceholder":794},[746,1932,1933],{"class":448,"line":1476},[746,1934,1935],{"class":751},"      # Layer 3 — e2e with Playwright + axe.\n",[746,1937,1938,1940,1942,1944],{"class":448,"line":1482},[746,1939,1823],{"class":777},[746,1941,1878],{"class":1762},[746,1943,1766],{"class":777},[746,1945,1946],{"class":784},"npx playwright install --with-deps chromium\n",[746,1948,1949,1951,1953,1955],{"class":448,"line":1507},[746,1950,1823],{"class":777},[746,1952,1878],{"class":1762},[746,1954,1766],{"class":777},[746,1956,1957],{"class":784},"npm run test:e2e\n",[746,1959,1960],{"class":448,"line":1512},[746,1961,795],{"emptyLinePlaceholder":794},[746,1963,1964],{"class":448,"line":1518},[746,1965,1966],{"class":751},"      # Layer 4 — Lighthouse budget. A non-zero exit fails the whole job,\n",[746,1968,1969],{"class":448,"line":1534},[746,1970,1971],{"class":751},"      # which blocks the merge when the branch is protected.\n",[746,1973,1974,1976,1978,1980],{"class":448,"line":1548},[746,1975,1823],{"class":777},[746,1977,1878],{"class":1762},[746,1979,1766],{"class":777},[746,1981,1982],{"class":784},"npm run build && npm run start &\n",[746,1984,1985,1987,1989,1991],{"class":448,"line":1576},[746,1986,1823],{"class":777},[746,1988,1878],{"class":1762},[746,1990,1766],{"class":777},[746,1992,1993],{"class":784},"npx wait-on http:\u002F\u002Flocalhost:3000\n",[746,1995,1997,1999,2001,2003],{"class":448,"line":1996},28,[746,1998,1823],{"class":777},[746,2000,1878],{"class":1762},[746,2002,1766],{"class":777},[746,2004,2005],{"class":784},"npx @lhci\u002Fcli autorun\n",[324,2007,2008,2009,2012,2013,2016,2017,2020,2021,689],{},"Two practical disciplines make a gate survivable. First, ",[515,2010,2011],{},"start with a baseline."," Turning on a hard gate against a legacy app with hundreds of existing violations will block every PR; instead, snapshot the current violations and fail only on ",[340,2014,2015],{},"new"," ones, then burn the baseline down over time. Second, ",[515,2018,2019],{},"fail loud and specific"," — surface the exact axe rule id, the failing selector, and a link to the rule's help page in the CI output, so an engineer can fix the violation without leaving the PR. The full gating playbook — baselines, severity thresholds, branch protection, and reporting — is in ",[518,2022,297],{"href":552},[498,2024],{},[501,2026,2028],{"id":2027},"manual-verification-that-must-remain","Manual Verification That Must Remain",[324,2030,2031],{},"No combination of the four automated layers certifies accessibility, because the most consequential criteria are experiential. A human must periodically verify the things the machine structurally cannot:",[509,2033,2034,2053,2068],{},[512,2035,2036,2039,2040,2043,2044,2047,2048,717,2051,597],{},[515,2037,2038],{},"Actual screen reader speech."," Open the flow in NVDA (Windows\u002FFirefox) and VoiceOver (macOS\u002FSafari) and ",[340,2041,2042],{},"listen",". Is the custom combobox announced as a combobox with its current value and option count? Does the live region read at the right moment, or get clobbered by a focus change? axe confirms the ARIA is ",[340,2045,2046],{},"valid","; only your ears confirm it's ",[340,2049,2050],{},"coherent",[328,2052,596],{},[512,2054,2055,2058,2059,721,2061,2064,2065,2067],{},[515,2056,2057],{},"Keyboard-only operation."," Unplug the mouse. Tab through the whole flow. Every interactive element must be reachable, operable, and have an order that matches the visual layout (",[328,2060,662],{},[328,2062,2063],{},"2.4.3 Focus Order","). Composite widgets need their arrow-key and ",[328,2066,346],{}," behaviors exercised by hand.",[512,2069,2070,2073,2074,2077,2078,2081,2082,2085],{},[515,2071,2072],{},"Visible focus."," Confirm a clearly visible focus indicator on ",[340,2075,2076],{},"every"," focusable element as you tab, including custom-styled controls where a CSS reset may have stripped the default outline (",[328,2079,2080],{},"2.4.7 Focus Visible","). This is trivial to break with a global ",[328,2083,2084],{},"outline: none"," and nearly impossible for a DOM-structure scan to catch.",[324,2087,2088,2089,2092],{},"The most efficient model is a ",[515,2090,2091],{},"standing manual checklist"," run on critical flows each release, with the automated layers preventing the regressions the checklist already caught from ever recurring. When a manual pass finds something, the follow-up is mechanical: encode it as a Playwright assertion or a jest-axe case so the machine guards that specific failure forever after. Manual testing is expensive; spend it on discovery, then let automation handle the repetition.",[498,2094],{},[501,2096,2098],{"id":2097},"key-takeaways","Key Takeaways",[509,2100,2101,2107,2113,2119,2125],{},[512,2102,2103,2106],{},[515,2104,2105],{},"Automation is a regression net, not a certificate."," It catches ~30–50% of WCAG criteria — the deterministic, high-volume failures humans miss in review. The other ~50–67% need a keyboard and a screen reader.",[512,2108,2109,2112],{},[515,2110,2111],{},"One engine, four layers."," axe-core underpins jest-axe, Playwright, and Lighthouse. Share a single rule config so the layers can't drift.",[512,2114,2115,2118],{},[515,2116,2117],{},"Match the tool to the question."," Static linting and jest-axe catch structure fast and cheap; Playwright is the only automated layer that verifies real keyboard, focus, and route behavior; Lighthouse is a coarse per-page budget tripwire.",[512,2120,2121,2124],{},[515,2122,2123],{},"A gate that doesn't block is just documentation."," Wire every layer into CI so violations fail the build, baseline legacy debt, and fail loud with rule ids and selectors.",[512,2126,2127,2130],{},[515,2128,2129],{},"Manual screen reader and keyboard testing wraps everything."," Spend it on discovery, then encode each finding as an automated assertion so it never regresses.",[498,2132],{},[501,2134,2136],{"id":2135},"frequently-asked-questions","Frequently Asked Questions",[324,2138,2139,2142],{},[515,2140,2141],{},"Does a clean automated scan prove my app is WCAG compliant?","\nNo. Automated engines like axe-core catch roughly 30–50% of WCAG success criteria, and within those they only flag machine-decidable failures. A score of 100 means \"no failures the tool can detect,\" not \"accessible.\" Issues like useful alt text, coherent screen reader announcements, and logical focus order require manual verification with a keyboard and a screen reader.",[324,2144,2145,2148,2149,2152],{},[515,2146,2147],{},"Do jest-axe, Playwright, and Lighthouse duplicate each other since they all use axe-core?","\nThey share the engine but test at different layers, so the overlap is intentional, not redundant. jest-axe runs in JSDOM (no contrast, no real focus) for fast per-component structural checks. Playwright runs the engine in a real browser ",[340,2150,2151],{},"and"," drives keyboard\u002Ffocus behavior the engine can't assess. Lighthouse produces a coarse per-page score for budget thresholds. Use all three — each answers a question the others can't.",[324,2154,2155,2158],{},[515,2156,2157],{},"Where should I run accessibility tests: pre-commit, PR, or deploy?","\nTier them to match cost. Static linting and jest-axe are fast enough for pre-commit and every push. Playwright e2e and Lighthouse are slower, so run them on pull requests as the blocking merge gate. Reserve full manual screen reader passes for release candidates. The goal is fast feedback on cheap checks and thorough enforcement before merge.",[324,2160,2161,2164,2165,2167],{},[515,2162,2163],{},"How do I introduce a blocking CI gate to a legacy app without blocking every PR?","\nSnapshot the existing violations as a baseline and configure the gate to fail only on ",[340,2166,2015],{}," violations beyond that baseline. This stops new regressions immediately while letting you burn down the legacy debt incrementally. A hard gate against an uncleaned legacy codebase will block every merge and get disabled within a week.",[324,2169,2170,2173,2174,2177,2178,2180],{},[515,2171,2172],{},"Can Playwright fully replace manual screen reader testing?","\nNo. Playwright can verify keyboard operability, focus order, focus restoration, and that valid ARIA is present — a large and valuable share of behavioral coverage. But it cannot tell you what a screen reader actually ",[340,2175,2176],{},"says"," or whether that speech is coherent to a user. Real NVDA and VoiceOver listening remains irreplaceable for ",[328,2179,596],{}," in custom widgets.",[324,2182,2183,2186,2187,2189,2190,2192,2193,2195,2196,2198],{},[515,2184,2185],{},"Which WCAG criteria are hardest to automate?","\nThe experiential and judgment-based ones. ",[328,2188,662],{}," operability of composite widgets, ",[328,2191,2063],{}," matching visual order, ",[328,2194,2080],{}," on custom-styled controls, and the meaningfulness half of ",[328,2197,596],{}," (valid ARIA is checkable; coherent ARIA is not). These are exactly where your manual testing budget should go.",[498,2200],{},[501,2202,2204],{"id":2203},"related-guides","Related guides",[509,2206,2207,2213,2218,2223,2228,2233,2238,2243,2248],{},[512,2208,2209,2212],{},[518,2210,2211],{"href":86},"Home"," — the full accessibility-for-frameworks library.",[512,2214,2215,2217],{},[518,2216,10],{"href":559}," — the foundations these tests verify.",[512,2219,2220,2222],{},[518,2221,94],{"href":563}," — implementation patterns that pass these gates.",[512,2224,2225,2227],{},[518,2226,243],{"href":520}," — the shared engine, configured in depth.",[512,2229,2230,2232],{},[518,2231,261],{"href":528}," — fast structural checks in JSDOM.",[512,2234,2235,2237],{},[518,2236,279],{"href":536}," — real keyboard, focus, and route behavior.",[512,2239,2240,2242],{},[518,2241,225],{"href":544}," — page-level scores and budgets.",[512,2244,2245,2247],{},[518,2246,297],{"href":552}," — making violations fail the build.",[512,2249,2250,2252],{},[518,2251,67],{"href":688}," — the manual layer that wraps all of the above.",[2254,2255,2256],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":742,"searchDepth":755,"depth":755,"links":2258},[2259,2260,2261,2262,2263,2264,2265,2266,2267,2268,2269],{"id":503,"depth":755,"text":504},{"id":577,"depth":755,"text":578},{"id":694,"depth":755,"text":695},{"id":960,"depth":755,"text":961},{"id":1186,"depth":755,"text":1187},{"id":1591,"depth":755,"text":1592},{"id":1737,"depth":755,"text":1738},{"id":2027,"depth":755,"text":2028},{"id":2097,"depth":755,"text":2098},{"id":2135,"depth":755,"text":2136},{"id":2203,"depth":755,"text":2204},null,"A complete workflow for testing and automating accessibility in modern frameworks—axe-core, jest-axe, Playwright, Lighthouse, and CI gates that block regressions.","md",{},false,{"title":222,"description":2271},"F-fbr7Xc-hB7TOMWS_FPV4y8J_Wu6PPqup3fRfqCqW8",[2278,2317,2318,2381],{"title":5,"path":6,"stem":7,"children":2279},[2280,2281,2284,2287,2293,2299,2308,2314],{"title":10,"path":6,"stem":11},{"title":13,"path":14,"stem":15,"children":2282},[2283],{"title":13,"path":14,"stem":15},{"title":19,"path":20,"stem":21,"children":2285},[2286],{"title":19,"path":20,"stem":21},{"title":25,"path":26,"stem":27,"children":2288},[2289,2290],{"title":25,"path":26,"stem":27},{"title":31,"path":32,"stem":33,"children":2291},[2292],{"title":31,"path":32,"stem":33},{"title":37,"path":38,"stem":39,"children":2294},[2295,2296],{"title":37,"path":38,"stem":39},{"title":43,"path":44,"stem":45,"children":2297},[2298],{"title":43,"path":44,"stem":45},{"title":49,"path":50,"stem":51,"children":2300},[2301,2302,2305],{"title":49,"path":50,"stem":51},{"title":55,"path":56,"stem":57,"children":2303},[2304],{"title":55,"path":56,"stem":57},{"title":61,"path":62,"stem":63,"children":2306},[2307],{"title":61,"path":62,"stem":63},{"title":67,"path":68,"stem":69,"children":2309},[2310,2311],{"title":67,"path":68,"stem":69},{"title":73,"path":74,"stem":75,"children":2312},[2313],{"title":73,"path":74,"stem":75},{"title":79,"path":80,"stem":81,"children":2315},[2316],{"title":79,"path":80,"stem":81},{"title":85,"path":86,"stem":87},{"title":89,"path":90,"stem":91,"children":2319},[2320,2321,2327,2339,2351,2354,2363,2375],{"title":94,"path":90,"stem":95},{"title":97,"path":98,"stem":99,"children":2322},[2323,2324],{"title":97,"path":98,"stem":99},{"title":103,"path":104,"stem":105,"children":2325},[2326],{"title":103,"path":104,"stem":105},{"title":109,"path":110,"stem":111,"children":2328},[2329,2330,2333,2336],{"title":109,"path":110,"stem":111},{"title":115,"path":116,"stem":117,"children":2331},[2332],{"title":115,"path":116,"stem":117},{"title":121,"path":122,"stem":123,"children":2334},[2335],{"title":121,"path":122,"stem":123},{"title":127,"path":128,"stem":129,"children":2337},[2338],{"title":127,"path":128,"stem":129},{"title":133,"path":134,"stem":135,"children":2340},[2341,2342,2345,2348],{"title":133,"path":134,"stem":135},{"title":139,"path":140,"stem":141,"children":2343},[2344],{"title":139,"path":140,"stem":141},{"title":145,"path":146,"stem":147,"children":2346},[2347],{"title":145,"path":146,"stem":147},{"title":151,"path":152,"stem":153,"children":2349},[2350],{"title":151,"path":152,"stem":153},{"title":157,"path":158,"stem":159,"children":2352},[2353],{"title":157,"path":158,"stem":159},{"title":163,"path":164,"stem":165,"children":2355},[2356,2357,2360],{"title":163,"path":164,"stem":165},{"title":169,"path":170,"stem":171,"children":2358},[2359],{"title":169,"path":170,"stem":171},{"title":175,"path":176,"stem":177,"children":2361},[2362],{"title":175,"path":176,"stem":177},{"title":181,"path":182,"stem":183,"children":2364},[2365,2366,2369,2372],{"title":181,"path":182,"stem":183},{"title":187,"path":188,"stem":189,"children":2367},[2368],{"title":187,"path":188,"stem":189},{"title":193,"path":194,"stem":195,"children":2370},[2371],{"title":193,"path":194,"stem":195},{"title":199,"path":200,"stem":201,"children":2373},[2374],{"title":199,"path":200,"stem":201},{"title":205,"path":206,"stem":207,"children":2376},[2377,2378],{"title":205,"path":206,"stem":207},{"title":211,"path":212,"stem":213,"children":2379},[2380],{"title":211,"path":212,"stem":213},{"title":217,"path":218,"stem":219,"children":2382},[2383,2384,2393,2402,2411,2420],{"title":222,"path":218,"stem":223},{"title":225,"path":226,"stem":227,"children":2385},[2386,2387,2390],{"title":225,"path":226,"stem":227},{"title":231,"path":232,"stem":233,"children":2388},[2389],{"title":231,"path":232,"stem":233},{"title":237,"path":238,"stem":239,"children":2391},[2392],{"title":237,"path":238,"stem":239},{"title":243,"path":244,"stem":245,"children":2394},[2395,2396,2399],{"title":243,"path":244,"stem":245},{"title":249,"path":250,"stem":251,"children":2397},[2398],{"title":249,"path":250,"stem":251},{"title":255,"path":256,"stem":257,"children":2400},[2401],{"title":255,"path":256,"stem":257},{"title":261,"path":262,"stem":263,"children":2403},[2404,2405,2408],{"title":261,"path":262,"stem":263},{"title":267,"path":268,"stem":269,"children":2406},[2407],{"title":267,"path":268,"stem":269},{"title":273,"path":274,"stem":275,"children":2409},[2410],{"title":273,"path":274,"stem":275},{"title":279,"path":280,"stem":281,"children":2412},[2413,2414,2417],{"title":279,"path":280,"stem":281},{"title":285,"path":286,"stem":287,"children":2415},[2416],{"title":285,"path":286,"stem":287},{"title":291,"path":292,"stem":293,"children":2418},[2419],{"title":291,"path":292,"stem":293},{"title":297,"path":298,"stem":299,"children":2421},[2422,2423,2426],{"title":297,"path":298,"stem":299},{"title":303,"path":304,"stem":305,"children":2424},[2425],{"title":303,"path":304,"stem":305},{"title":309,"path":310,"stem":311,"children":2427},[2428],{"title":309,"path":310,"stem":311},1781785523473]