Accessibility Architecture
Building accessible primitives into a design system, focus management for single-page apps, and where automated a11y testing stops being enough.
Advertisement
Why It Matters
Accessibility is the clearest example in frontend of a problem whose cost is set entirely by when you address it.
Build focus management, keyboard handling, and ARIA into your component primitives, and every feature built on them inherits it. Skip it, and you get the retrofit: an audit finds four hundred issues across two hundred components, each needing its own fix, its own review, and its own regression test. The work is the same in kind and differs by two orders of magnitude in volume.
That is an architecture decision about where behaviour lives - which is why it belongs in a system design conversation and not on a checklist.
Accessible Primitives in the Design System
The leverage point is the component library. If it is where interaction behaviour lives, it is where accessibility lives.
A properly built modal primitive owns, once:
- Focus moves into the dialog on open
- Focus is trapped while it is open - Tab cycles within, never behind it
- Escape closes it
- Focus returns to the trigger on close
role="dialog",aria-modal="true", and a labelling relationship to its title- Background content is inert, so a screen reader cannot wander into it
Every modal in the product then gets that for free. More importantly, a bug found in the primitive is fixed once, for all of them.
Diagram100%flowchart TB subgraph Feature["Feature layer - product teams"] F1["CheckoutModal"] F2["FilterDropdown"] F3["SettingsTabs"] F4["DatePickerField"] end subgraph Primitives["Primitive layer - accessibility lives HERE"] P1["Dialog<br/>focus trap, restore, Escape, aria-modal"] P2["Menu<br/>arrow keys, typeahead, roving tabindex"] P3["Tabs<br/>arrow keys, aria-selected, panel wiring"] P4["Field<br/>label association, aria-describedby, error linkage"] end subgraph Foundation["Foundation - tokens"] T1["Colour tokens<br/>contrast-verified pairs only"] T2["Focus ring token<br/>visible on every surface"] T3["Spacing tokens<br/>minimum target sizes"] end F1 --> P1 F2 --> P2 F3 --> P3 F4 --> P4 P1 --> T1 P1 --> T2 P2 --> T2 P3 --> T2 P4 --> T1 Fix["One fix in Dialog"] -.->|"fixes every modal in the product"| F1 style Primitives fill:#1e3a5f,stroke:#3b82f6 style Foundation fill:#1e3f2d,stroke:#22c55e style Feature fill:#0f172a,stroke:#64748bvisualized by
Two design rules make this hold up in practice.
Prefer native elements. A <button> is focusable, activates on Enter and Space, is announced as a button, and works with every assistive technology ever shipped. A <div role="button"> reproduces none of that behaviour - ARIA changes only what is reported, never what the element does. You have to add tabindex, key handlers, and disabled semantics by hand, and you will miss one. Reach for ARIA only when no native element expresses what you are building.
Make the accessible path the default path. If a component requires the consumer to pass a label to be accessible, some consumers will not. Make the label a required prop, or fail loudly in development. Accessibility that depends on every consumer remembering is accessibility that degrades.
The foundation layer matters too, and is often forgotten: if your colour tokens only contain contrast-verified pairs, contrast failures become difficult to author rather than easy to miss.
Focus Management in SPAs
Focus is how keyboard and screen reader users know where they are. Client-side routing breaks it by default, and it is the most common serious accessibility bug in modern applications.
Route changes
In a real page load, the browser resets focus to the document and the screen reader announces the new page. A client-side route change does none of that. The DOM swaps, and focus stays on whatever the user activated - a link that has just been removed, at which point focus silently falls back to <body>.
For a screen reader user, nothing was announced: the page changed and they were not told. For a keyboard user, the next Tab starts from the top of the document rather than from the content they just navigated to.
Diagram100%flowchart TB A["User activates a nav link"] --> B["Route change begins"] B --> C["Save the trigger element<br/>needed for back-navigation restore"] C --> D["New route renders"] D --> E{"Is there a<br/>heading or main<br/>landmark?"} E -->|"yes"| F["focus() the h1 or main<br/>with tabindex='-1'"] E -->|"no"| G["focus() the app container<br/>and fix the missing landmark"] F --> H["Announce in a polite<br/>live region: 'Checkout, page loaded'"] G --> H H --> I["Screen reader announces the new page<br/>Tab continues from the new content"] X["DEFAULT BEHAVIOUR<br/>no focus management"] -.-> Y["Focus falls to body.<br/>Nothing announced.<br/>Tab restarts at the top."] style X fill:#3f1e1e,stroke:#ef4444 style Y fill:#3f1e1e,stroke:#ef4444 style I fill:#1e3f2d,stroke:#22c55evisualized by
The fix: after the route resolves, move focus to the new page's <h1> or <main> (given tabindex="-1" so it can receive programmatic focus), and announce the change in a polite live region. Do this once in the router, not in every page component.
Modals and overlays
Three obligations, in order:
- On open, move focus into the dialog - to the first interactive element, or the dialog container if the content is primarily text.
- While open, trap it. Tab from the last focusable element wraps to the first. Nothing behind the overlay is reachable, by keyboard or by screen reader - which requires making the background inert, not merely visually obscured.
- On close, restore focus to the element that opened it. Skipping this drops the user back to the top of the document with no idea where they were.
Dynamic content
Content that appears without a navigation - a validation error, a toast, a loaded search result - is invisible to a screen reader unless announced. Live regions handle this: aria-live="polite" waits for a pause, aria-live="assertive" interrupts.
Use assertive sparingly. It interrupts whatever the user is currently hearing, and a page that interrupts constantly is worse than one that is quiet. Errors that block progress justify it; a toast confirming a save does not.
Testing Strategy in the Pipeline
Automated tooling catches roughly a third of real accessibility issues. That is genuinely valuable and it is nowhere near sufficient, and both halves of that sentence matter.
Linting (eslint-plugin-jsx-a11y) runs in the editor and on every commit. It catches static mistakes - an image without alt, an onClick on a non-interactive element with no keyboard handler, an invalid ARIA attribute - in milliseconds, before the code is even saved. Cheapest possible feedback.
Automated rule checks (axe-core, via jest-axe or the Playwright integration) run against rendered output, so they catch what static analysis cannot: computed colour contrast, whether a form control ends up with an accessible name, whether ARIA relationships resolve to elements that exist. Run these per component in the unit and integration suite, and over a small set of critical full pages pre-merge - composition produces issues that individual components do not have, such as duplicate landmarks or a broken heading sequence.
Manual testing is where the other two thirds live, and it cannot be automated away because the questions are semantic:
- Is the alt text useful, or does it say "image"?
- Does the heading hierarchy reflect the actual structure of the content?
- Does tab order follow the visual reading order?
- Does this custom widget behave the way a screen reader user expects a widget of that kind to behave?
- Is this error message comprehensible to someone who cannot see which field is highlighted?
None of those have a machine-checkable answer. A page can pass every automated rule and be unusable.
Because manual testing cannot gate every commit, it needs a different placement: keyboard-only walkthrough as part of accepting any new component, screen reader testing on new interaction patterns, and a periodic audit of the critical journeys. The goal is to catch pattern-level problems early, where the primitive layer means one fix covers everything downstream.
Tradeoffs
| Option | Pros | Cons | When to Use |
|---|---|---|---|
| Accessible primitives in the design system | One fix covers every consumer, features inherit correctness for free | Upfront investment, primitives must be genuinely flexible or teams route around them | Any product with more than a handful of components |
| Per-feature accessibility | No upfront cost, teams move immediately | Every component is an independent bug, retrofit cost grows superlinearly | Prototypes with a real deadline for consolidation |
| Native elements | Full behaviour and semantics for free, works everywhere | Styling constraints on some controls | Always, unless no native element fits |
| ARIA on custom elements | Expresses patterns the platform lacks | Changes reporting only, never behaviour - all interaction is hand-built | Tabs, comboboxes, trees, and other genuinely absent patterns |
| Lint plus automated rule checks | Fast, cheap, blocks regressions on every commit | Catches roughly a third of real issues, cannot judge semantics | Baseline for every project |
| Manual keyboard and screen reader testing | Catches the issues that actually make a product unusable | Slow, needs skill, cannot gate every commit | New components, new interaction patterns, periodic journey audits |
Where This Applies
The primitive layer described here is the design system whose distribution model is decided in Application Architecture at Scale - and how you ship it determines how quickly an accessibility fix reaches consumers. The placement of a11y checks across CI gates follows the pyramid and gate model in Testing Strategy. Focus restoration after a route change depends on the routing and rendering model in Rendering Architecture, and RTL layout - a related architectural concern - is covered in Internationalization Architecture.
In the applied practice problems, this shows up first in Autocomplete / Typeahead, which is the combobox pattern and one of the hardest widgets to get right, and in Drag and Drop, where dragging cannot be made accessible by adding attributes and needs a keyboard interaction model built alongside it. The two-dimensional grid pattern is worked through in Date Picker, and focus management across an overlay in Image Gallery with Lazy Loading.
Advertisement
Why is accessibility framed as an architecture problem rather than a checklist?
Because the cost profile is completely different depending on when you address it. If your modal primitive traps focus, restores it on close, and wires aria-modal correctly, then every one of the two hundred modals built on it is accessible for free, and a fix to the primitive fixes all of them at once. If each team built its own modal, you have two hundred independent bugs, each needing its own fix, its own review, and its own regression test. The work is identical in kind and differs by two orders of magnitude in volume - which makes it an architecture decision about where behaviour lives, not a checklist item.
What happens to focus when a single-page app changes route, and why does it matter?
Nothing happens, which is the problem. In a real page load the browser resets focus to the document and screen readers announce the new page. A client-side route change swaps the DOM without any of that, so focus stays on whatever the user activated - a link that no longer exists, at which point focus falls back to the body. A screen reader user gets no announcement that navigation occurred, and a keyboard user pressing Tab starts from the top of the document rather than from the new content. The fix is to explicitly move focus to the new page's main heading or main landmark after the route resolves, and to announce the change in a live region.
Automated a11y tools pass on your page. What can they still not tell you?
Whether any of it makes sense. Automated tools reliably catch missing alt attributes, insufficient contrast, missing form labels, and invalid ARIA - roughly a third of real issues. They cannot judge whether alt text describes the image usefully or just says image, whether the heading hierarchy reflects the actual structure of the content, whether the tab order matches the visual reading order, whether a custom widget behaves the way a screen reader user expects, or whether an error message is understandable. A page can pass every automated check and be unusable, which is why manual keyboard and screen reader testing stays in the process.
When should you use ARIA, and what is the main risk of using it?
Use it when no native element expresses what you are building - a tab set, a combobox, a tree view. The risk is that ARIA changes only what assistive technology reports, never actual behaviour, so a div with role=button is announced as a button while remaining unfocusable and unresponsive to Enter and Space. You have made a promise the element does not keep, which is worse than no ARIA at all because it removes the user's ability to tell that something is wrong. The rule that follows is to prefer the native element every time and reach for ARIA only when there genuinely is not one.
Where do the different a11y test types belong in a pipeline?
Match each gate to what it can catch cheaply. Lint runs in the editor and on every commit, catching static JSX-level mistakes in milliseconds. Automated rule checks against rendered components run in the unit and integration suite, catching contrast, labelling, and ARIA validity per component. A small set of full-page automated scans runs pre-merge over the critical flows, catching issues that only appear once components are composed. Manual keyboard and screen reader testing cannot be a per-commit gate, so it goes on new component acceptance and on a periodic audit of key journeys.