Search…

Accessibility as a system design concern

In this series (12 parts)
  1. What frontend system design covers
  2. Rendering strategies: CSR, SSR, SSG, ISR
  3. Performance fundamentals: Core Web Vitals
  4. Loading performance and resource optimization
  5. State management at scale
  6. Component architecture and design systems
  7. Client-side caching and offline support
  8. Real-time on the frontend
  9. Frontend security
  10. Scalability for frontend systems
  11. Accessibility as a system design concern
  12. Monitoring and observability for frontends

Prerequisite: Scalability for frontend systems.

Accessibility is not a nice-to-have. In many jurisdictions it is a legal requirement. More importantly, it determines whether your application is usable by people with visual, motor, auditory, or cognitive disabilities. That is roughly 15% of the global population.

This article treats accessibility as a system design concern, not a checklist of ARIA attributes. We will cover the standards, the HTML and ARIA primitives, the SPA-specific challenges, and how to build an automated testing pipeline that catches regressions before they ship.


WCAG levels

The Web Content Accessibility Guidelines (WCAG) define three conformance levels:

  • Level A: the minimum. Missing these makes your site unusable for many assistive technology users. Examples: all images have alt text, all form inputs have labels, no keyboard traps.
  • Level AA: the standard target for most organizations. Adds requirements like sufficient color contrast (4.5:1 for normal text), visible focus indicators, and error identification.
  • Level AAA: aspirational. Stricter contrast ratios (7:1), sign language for video, simpler reading levels. Few sites achieve full AAA compliance.

Most regulatory requirements (ADA in the US, EN 301 549 in the EU) reference WCAG 2.1 Level AA. Design your system to meet AA as the baseline.


Semantic HTML vs ARIA

The first rule of ARIA is: do not use ARIA if a native HTML element does the job. Screen readers understand <button>, <nav>, <main>, <input type="checkbox"> natively. Adding role="button" to a <div> requires you to also implement keyboard handling, focus management, and state announcements that the native element provides for free.

When to use ARIA

  • Custom components with no native equivalent (combobox, tree view, tab panel).
  • Dynamic content that changes without a page reload (live regions).
  • Relationships between elements that are not expressed by the DOM structure (aria-labelledby, aria-describedby, aria-controls).

Common mistakes

  • aria-label on non-interactive elements. Screen readers may ignore aria-label on <div> or <span> unless they have a role.
  • Duplicate labels. If a <label> element is already associated with an input, adding aria-label can create confusion.
  • Missing aria-live for dynamic content. When new content appears (a toast notification, an error message), screen readers do not announce it unless the container has aria-live="polite" or aria-live="assertive".

Keyboard navigation

Every interactive element must be operable with a keyboard. This means:

  • All clickable elements are focusable (native <button> and <a> are; <div onclick> is not).
  • Focus order follows a logical reading sequence.
  • Custom components implement the expected keyboard patterns (arrow keys for menus, Escape to close dialogs, Enter to activate).

Focus traps

Modal dialogs must trap focus. When a dialog is open, Tab and Shift+Tab should cycle through the dialog’s focusable elements and not escape into the background content. When the dialog closes, focus returns to the element that opened it.

function trapFocus(dialog) {
  const focusable = dialog.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  dialog.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  });

  first.focus();
}

Focus management in SPAs

Traditional multi-page sites get focus management for free. Each navigation loads a new page, and the browser resets focus to the top of the document. SPAs do not get this.

When a user clicks a link in a SPA and the route changes client-side, the screen reader has no idea the page changed. The focus stays wherever it was. The user hears nothing.

Solutions

  1. Move focus to the main content heading after each route change. This announces the new page title and places the user at the start of the content.
  2. Use a live region to announce the page title. A visually hidden <div aria-live="polite"> that updates with the new page name.
  3. Update document.title on every route change. Screen readers may announce it; it also helps users with many tabs.

Most routing libraries (React Router, Vue Router) do not handle this automatically. You need to add it yourself.

flowchart TD
  A[Route Change Triggered] --> B[Update URL]
  B --> C[Render New View]
  C --> D[Update document.title]
  D --> E[Move Focus to Main Heading]
  E --> F[Announce Page Change via Live Region]
  F --> G[User Knows Where They Are]

Focus management flow for SPA route changes. Without explicit focus handling, screen reader users are left stranded after navigation.


Screen reader behavior

Screen readers navigate the page using a virtual buffer, a representation of the DOM. They read content linearly and provide shortcuts to jump between headings, landmarks, links, and form controls.

What this means for your markup

  • Heading hierarchy matters. Use <h1> through <h6> in order. Skipping from <h1> to <h4> breaks the navigation model. Screen reader users scan headings like sighted users scan visual hierarchy.
  • Landmarks structure the page. <nav>, <main>, <aside>, <footer> let users jump between sections. If you have multiple <nav> elements, give each an aria-label to distinguish them.
  • Link text must be descriptive. “Click here” is meaningless when a screen reader lists all links on a page. Use “View order history” instead.
  • Tables need headers. Use <th> with scope="col" or scope="row". Without them, data tables are a wall of unrelated cells.

Automated vs manual testing

Automated tools catch roughly 30 to 50% of accessibility issues. The rest require human judgment.

Automated tools excel at structural checks but cannot evaluate the user experience with assistive technology.

Automated tools

  • axe-core (via @axe-core/playwright or jest-axe): run in CI on every PR. Fast and catches the structural issues.
  • Lighthouse accessibility audit: good for a quick score but less granular than axe.
  • eslint-plugin-jsx-a11y: catches issues at authoring time in React components.

Manual testing

  • Tab through every page with a keyboard. Every interactive element should be reachable and operable.
  • Test with a screen reader. VoiceOver on macOS, NVDA on Windows. At minimum, verify that page navigation, forms, and dynamic content announcements work.
  • Test with zoom at 200%. Content should reflow without horizontal scrolling.
flowchart LR
  A[Code authored] --> B[ESLint a11y plugin]
  B --> C[Unit tests with jest-axe]
  C --> D[Integration tests with axe-core]
  D --> E[Lighthouse in CI]
  E --> F[Manual screen reader test]
  F --> G[User testing with disabled users]

Accessibility testing pipeline. Automated checks catch the easy wins early; manual and user testing catches the rest.


Accessibility in design systems

If your organization has a design system, it is the highest-leverage place to invest in accessibility. A button component used across 200 screens either makes all of them accessible or none of them.

Principles

  • Bake in, do not bolt on. The default state of every component should be accessible. If a developer has to remember to add aria-label, they will forget.
  • Require labels via API. Make label props required. A <TextInput> component without a label prop should fail TypeScript compilation.
  • Document keyboard interactions. Each component’s docs should list the expected keyboard behavior.
  • Include accessibility in code review criteria. A PR that changes a component’s DOM structure should be reviewed for screen reader impact.

Testing in the design system

Run axe-core on every component’s Storybook stories. Use Storybook’s accessibility addon to surface violations during development. This catches issues before components are consumed by product teams.


Color and contrast

Color contrast failures are the most common accessibility issue on the web. WCAG AA requires:

  • 4.5:1 contrast ratio for normal text (below 18pt or 14pt bold).
  • 3:1 for large text (18pt and above, or 14pt bold and above).
  • 3:1 for UI components and graphical objects.

Do not convey information through color alone. A red error message is invisible to someone with red-green color blindness. Add an icon, underline, or text prefix like “Error:” alongside the color change.

Design tokens in your design system should include only colors that pass contrast checks against their intended backgrounds. This prevents teams from accidentally choosing inaccessible combinations.


What comes next

An accessible, scalable frontend still needs eyes on it in production. Monitoring and observability for frontends covers how to measure what real users experience, track errors, and build dashboards that surface problems before your users file tickets.

Start typing to search across all content
navigate Enter open Esc close