Accessibility in Depth#

Web accessibility means building interfaces that work for everyone — including people who use screen readers, keyboard-only navigation, magnification, voice control, or switch devices. This section covers ARIA, testing tools, and the patterns that make complex UI components accessible.


WCAG 2.2 Levels#

LevelStandardRequirement
AMinimumBasic accessibility (alt text, keyboard navigation, no seizure triggers)
AAIndustry standardColour contrast ≥ 4.5:1, focus visible, consistent navigation, error identification
AAAEnhancedContrast ≥ 7:1, sign language, extended audio descriptions (rarely required)

Most legal requirements and organisational standards target Level AA.


ARIA (Accessible Rich Internet Applications)#

ARIA attributes add semantic information to elements that HTML alone cannot express.

The First Rule of ARIA#

No ARIA is better than bad ARIA.

Use semantic HTML first. ARIA is a last resort for custom components that have no native HTML equivalent.

<!-- GOOD — no ARIA needed -->
<button>Submit</button>
<nav>...</nav>
<input type="checkbox">

<!-- BAD — ARIA reimplements what HTML already provides -->
<div role="button" tabindex="0" aria-pressed="false">Submit</div>

Key ARIA Attributes#

AttributePurposeExample
roleDefines what the element ISrole="dialog", role="tablist"
aria-labelAccessible name (when no visible text)<button aria-label="Close">×</button>
aria-labelledbyReferences visible text as labelaria-labelledby="section-heading"
aria-describedbyReferences additional descriptionaria-describedby="password-hint"
aria-hiddenHides from accessibility tree<div aria-hidden="true">decorative icon</div>
aria-liveAnnounces dynamic content changesaria-live="polite" for status updates
aria-expandedCollapsible statearia-expanded="false" on dropdown trigger
aria-selectedSelection statearia-selected="true" on active tab
aria-invalidValidation statearia-invalid="true" on errored input
aria-requiredRequired fieldaria-required="true"

Live Regions#

Announce dynamic content to screen readers without focus change:

<!-- Polite: announces after current speech finishes -->
<div aria-live="polite" aria-atomic="true">
    3 items in cart
</div>

<!-- Assertive: interrupts current speech (use sparingly) -->
<div role="alert">
    Payment failed. Please try again.
</div>

<!-- Status: polite equivalent for status messages -->
<div role="status">
    Search results: 42 items found
</div>

Keyboard Navigation Patterns#

Focus Management#

KeyExpected Behaviour
TabMove to next focusable element
Shift+TabMove to previous focusable element
Enter/SpaceActivate focused element
EscapeClose modal/dropdown, cancel
Arrow keysNavigate within composite widgets (tabs, menus, grids)

Focus Trapping (Modals)#

When a modal is open, Tab should cycle through elements inside it — not escape to the page behind:

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

    modal.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();
}

Allow keyboard users to skip repetitive navigation:

<body>
    <a href="#main-content" class="skip-link">Skip to main content</a>
    <nav><!-- long navigation --></nav>
    <main id="main-content"><!-- page content --></main>
</body>

<style>
.skip-link {
    position: absolute;
    top: -40px;
    left: 0;
    z-index: 100;
}
.skip-link:focus {
    top: 0;  /* visible when focused */
}
</style>

Common ARIA Patterns#

Tabs#

<div role="tablist" aria-label="Settings">
    <button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1">General</button>
    <button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2" tabindex="-1">Security</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">General content</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>Security content</div>

Arrow keys navigate between tabs. Tab key moves focus into the panel content.

Disclosure (Accordion)#

<button aria-expanded="false" aria-controls="details-1">
    Show details
</button>
<div id="details-1" hidden>
    Expanded content here.
</div>

Combobox (Autocomplete)#

<label for="city">City</label>
<input id="city" role="combobox" aria-expanded="false"
       aria-autocomplete="list" aria-controls="city-listbox">
<ul id="city-listbox" role="listbox" hidden>
    <li role="option" id="opt-1">Barcelona</li>
    <li role="option" id="opt-2">Berlin</li>
</ul>

Testing Accessibility#

Automated Tools#

ToolTypeWhat It Catches
axe-coreLibrary / browser extension~50% of WCAG violations
LighthouseChrome DevToolsAccessibility score + specific issues
pa11yCLI / CIAutomated WCAG checking
eslint-plugin-jsx-a11yESLint pluginReact accessibility issues at build time
@axe-core/playwrightPlaywright integrationAutomated accessibility in E2E tests

Playwright + axe Example#

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

test('home page passes accessibility checks', async ({ page }) => {
    await page.goto('/');
    const results = await new AxeBuilder({ page }).analyze();
    expect(results.violations).toEqual([]);
});

test('form page passes WCAG AA', async ({ page }) => {
    await page.goto('/contact');
    const results = await new AxeBuilder({ page })
        .withTags(['wcag2a', 'wcag2aa'])
        .analyze();
    expect(results.violations).toEqual([]);
});

Manual Testing Checklist#

  • Navigate the entire page using only the keyboard (Tab, Enter, Escape, arrows)
  • Test with a screen reader (VoiceOver on macOS, NVDA on Windows)
  • Verify all interactive elements have visible focus indicators
  • Check colour contrast with browser DevTools or WebAIM contrast checker
  • Zoom to 200% — is all content still usable?
  • Test with browser extensions disabled (some users can’t run JS)
  • Verify form errors are announced and associated with inputs

Key Takeaways#

  • Use semantic HTML first. ARIA is a repair tool for custom components, not a first choice.
  • Live regions (aria-live, role="alert") announce dynamic content changes to screen readers.
  • Keyboard navigation must work for every interactive element. Modals must trap focus.
  • Automated tools catch ~50% of issues. Manual testing (keyboard + screen reader) catches the rest.
  • Target WCAG 2.2 Level AA. Test with axe-core in CI and VoiceOver/NVDA manually.
  • Accessibility is not a feature — it is a quality attribute. Build it in from the start, not as a retrofit.