Overview
Building UIs that work for all users — the browser and AT mechanics underneath ARIA, focus management, keyboard patterns, and visual accessibility.
Accessibility
Accessibility is not a checklist — it's an understanding of how users who rely on assistive technology experience your UI, and how the browser exposes that UI to them. This section covers the browser and AT mechanics underneath the ARIA spec, not just which attributes to add.
The section moves from the browser model that underpins all accessibility, through dynamic content patterns that break it most often, then interaction design, and finally visual considerations.
What's Covered
Accessibility Tree — The browser builds a parallel tree from the DOM for AT to query through platform APIs (MSAA/UIA on Windows, AX API on macOS, ATK/AT-SPI on Linux). Role mapping: <button> → button, <nav> → navigation, <div>/<span> → none (pruned unless given role or focus). Pruning rules: display: none, visibility: hidden, hidden attribute, aria-hidden="true" all remove elements — aria-hidden is inherited by all descendants, making it dangerous on containers with focusable children. Accessible name computation order: aria-labelledby (highest priority) → aria-label → native labelling (<label>, alt, <caption>, title) → text content. aria-labelledby vs aria-describedby: name identifies the element (read first); description provides supplementary detail (read after the name with a pause). Live region registration at parse time — containers must be in initial HTML; only content updates. Inspecting the tree: Chrome DevTools Elements → Accessibility tab; Firefox Accessibility panel.
ARIA Live Regions Internals — Browser pipeline: DOM mutation → browser accessibility layer detects change → accessibility tree diff → announcement queued in AT speech buffer → AT reads according to politeness. Politeness levels: aria-live="polite" / role="status" — waits for current speech to finish; aria-live="assertive" / role="alert" — interrupts immediately (reserve for errors only). aria-atomic="true": reads entire region as one unit on any change — required for compound messages like "Step 2 of 5" where partial text is meaningless. aria-relevant: controls which mutation types trigger announcements — default "additions text" covers most cases; "additions" only for announcement-on-add lists. Must-be-present-on-load rule: live region containers must be in initial server-rendered HTML; injecting role="alert" dynamically after page load produces no announcement in many AT. Double-update trick for idempotent messages: setMessage("") → requestAnimationFrame(() => setMessage("Saved")) — forces two DOM mutations so re-announcement fires even when the previous message was identical.
Focus Management in SPAs — Three mandatory focus management scenarios: route changes (move focus to visually hidden heading via usePathname + useEffect), modal open/close (trap focus on open, return to trigger in cleanup), async feedback (move focus to result/error element). Why .focus() must be in useEffect: React state updates are async — the target DOM element may not exist yet when the event handler runs; useEffect fires after the DOM is committed. tabIndex={-1}: makes non-interactive elements (<div>, <p>, <h1>, <main>) programmatically focusable without inserting them into the natural Tab order. Manual focus trap implementation: querySelectorAll(FOCUSABLE_SELECTORS) + keydown listener wrapping Tab/Shift+Tab at first/last focusable element. inert attribute (baseline 2023): applying inert to all sibling elements outside the modal makes their entire subtrees unfocusable, non-interactive, and invisible to AT — no manual Tab-wrapping needed. Trigger restoration: always store the trigger element in a ref before modal opens; call triggerRef.current?.focus() in useEffect cleanup (runs on close). Skip navigation link: visually hidden <a href="#main-content"> as first Tab stop; tabIndex={-1} on <main> to receive programmatic focus; satisfies WCAG 2.4.1 Bypass Blocks.
Keyboard Navigation Patterns — Tab order: follows DOM order; only natively interactive elements are in it by default. Roving tabindex: one element in a group has tabIndex={0}, all others tabIndex={-1}; arrow keys transfer the tabIndex={0} and move focus; Tab exits the group — required for toolbars, tab lists, radio groups, grids. ARIA APG toolbar keyboard contract: ArrowLeft/ArrowRight navigate; Home/End jump to first/last; wraps at boundaries. Tab list contract: same arrow keys plus role="tablist", role="tab", role="tabpanel", aria-controls, aria-selected="true/false", hidden attribute on inactive panels. Dialog focus trap: query all focusable elements; wrap Tab at last → first and Shift+Tab at first → last; Escape closes and returns focus to trigger. :focus-visible vs :focus: :focus-visible is suppressed for mouse/touch clicks; shows ring only for keyboard navigation and text inputs — use this for focus indicator styles, never * { outline: none }. aria-expanded on toggle buttons, aria-haspopup on buttons that open menus/dialogs, aria-selected on tab items — all must stay in sync with JS state. Pointer events: the unified model for mouse, touch, and stylus input (pointerdown, pointermove, pointerup), setPointerCapture for drag, touch-action: none for disabling browser gestures, and WCAG 2.5.1 requiring keyboard-accessible alternatives for every gesture interaction.
Color Contrast & Motion — Relative luminance algorithm: linearise sRGB values (piecewise gamma correction), weight by perceptual contribution (R: 21.26%, G: 71.52%, B: 7.22%). Contrast ratio: (L_lighter + 0.05) / (L_darker + 0.05). WCAG AA minimums: 4.5:1 normal text, 3:1 large text (≥18pt/≥14pt bold) and UI components (button borders, input outlines, icons). Common failure: Tailwind gray-500 (#6b7280) on white = 4.48:1 — just below the 4.5:1 threshold; use gray-600 or darker for body text. Programmatic audit: contrastRatio(fg, bg) utility + Vitest contract tests on design token pairs in CI. prefers-reduced-motion: reduce: OS-level setting (macOS, iOS, Windows, Android) exposed via CSS media query and window.matchMedia(). Vestibular sensitivity is triggered by spatial movement (transform, translate, scale, rotate, parallax) — NOT by opacity fades. Correct override: remove transform animations, replace with short opacity transitions. Tailwind: prefer motion-safe: (add motion as enhancement) over motion-reduce: (remove motion reactively). React useReducedMotion hook: window.matchMedia(QUERY).matches with addEventListener("change", onChange) subscription for runtime updates. Contrast in interactive states: focus indicators must contrast 3:1 against surrounding colour (WCAG 1.4.11); hover/active states need their own contrast checks; placeholder text is frequently failing.
Memory Profiling
A practical walkthrough of Chrome DevTools memory tooling — Heap Snapshot (Summary vs Comparison vs Containment vs Dominators), Allocation Timeline, Allocation Sampling, the Retainer tree, shallow vs retained size, performance.measureUserAgentSpecificMemory(), and a step-by-step debugging workflow.
Accessibility Tree
How the browser builds a parallel accessibility tree from the DOM — element mapping, pruning rules, accessible name computation order, role and state propagation, live region registration, and how to inspect and fix the tree in DevTools.