Provide expert guidance on building inclusive web experiences that conform to WCAG 2.1/2.2 at AA and AAA levels. This skill covers semantic HTML, ARIA patterns, keyboard navigation, screen reader compatibility, color and motion sensitivity, form accessibility, and testing with assistive technologies. Accessibility is not an afterthought — it is a core quality attribute of production software.
WCAG 2.1/2.2 Quick Reference
Conformance Levels
Level A — Minimum baseline. Removes the most severe barriers.
Level AA — Standard target for most websites and legal compliance.
Level AAA — Highest level. Not required for entire sites but target for critical flows.
The Four Principles (POUR)
Principle
Meaning
Key Success Criteria
Perceivable
Content available to all senses
Text alternatives, captions, contrast, resize
Operable
UI navigable by all input methods
Keyboard, timing, seizures, navigation
Understandable
Content and UI are predictable
Readable, predictable, input assistance
Robust
Works with current and future assistive tech
Parsing, name/role/value, status messages
Critical AA Criteria
Criterion
ID
Requirement
Non-text Content
1.1.1
All images have text alternatives
Color Contrast (text)
1.4.3
4.5:1 normal text, 3:1 large text (18px+ bold or 24px+)
Color Contrast (UI)
1.4.11
3:1 for UI components and graphical objects
Resize Text
1.4.4
Content readable at 200% zoom
Reflow
1.4.10
No horizontal scroll at 320px width
Keyboard
2.1.1
All functionality available from keyboard
No Keyboard Trap
2.1.2
Focus can always be moved away from any component
Focus Visible
2.4.7
Keyboard focus indicator is visible
Focus Not Obscured
2.4.11
Focused item not fully hidden by other content (WCAG 2.2)
Heading Structure
1.3.1
Headings convey document structure
Link Purpose
2.4.4
Link text describes destination (no "click here")
Error Identification
3.3.1
Errors identified and described in text
Labels or Instructions
3.3.2
Input fields have labels
Name, Role, Value
4.1.2
Custom controls expose name, role, state to AT
Status Messages
4.1.3
Status updates announced without focus change
AAA Enhancements (Target for Key Flows)
Criterion
ID
Requirement
Enhanced Contrast
1.4.6
7:1 normal text, 4.5:1 large text
Focus Appearance
2.4.13
Focus indicator meets minimum area and contrast (WCAG 2.2)
Target Size
2.5.5
Interactive targets at least 44x44 CSS pixels
Error Prevention
3.3.4
Reversible submissions for legal/financial data
Semantic HTML and Landmarks
Use the Right Element
<!-- BAD: div soup with ARIA bolted on --><divrole="navigation"><divrole="list"><divrole="listitem"><divrole="link"tabindex="0"onclick="...">Home</div></div></div></div><!-- GOOD: semantic HTML needs no ARIA --><navaria-label="Main"><ul><li><ahref="/">Home</a></li><li><ahref="/about">About</a></li><li><ahref="/contact">Contact</a></li></ul></nav>
First Rule of ARIA: Do not use ARIA if a native HTML element provides the semantics you need.
Landmark Regions
Every page must have these landmarks. Screen reader users navigate by landmarks.
Use ARIA only for custom widgets that have no native HTML equivalent — tabs, accordions, tree views, comboboxes, toolbars.
Essential Attributes
// aria-label: names an element when no visible text exists
<button aria-label="Close dialog">
<XIconclassName="w-5 h-5" />
</button>
// aria-labelledby: references another element as the label<sectionaria-labelledby="stats-heading"><h2id="stats-heading">Monthly Statistics</h2></section>// aria-describedby: references supplementary description<inputid="password"type="password"aria-describedby="password-hint password-error"
/><pid="password-hint">Must be at least 8 characters.</p><pid="password-error"role="alert">Password is too short.</p>// aria-expanded: communicates open/closed state<buttonaria-expanded={isOpen}aria-controls="menu-panel">
Options
</button><divid="menu-panel"role="menu"hidden={!isOpen}>...</div>// aria-current: indicates current item in a set<navaria-label="Breadcrumb"><ol><li><ahref="/">Home</a></li><li><ahref="/products">Products</a></li><li><ahref="/products/shoes"aria-current="page">Shoes</a></li></ol></nav>// aria-live: announces dynamic content changes<divaria-live="polite"aria-atomic="true">
{statusMessage}
</div>// aria-busy: indicates loading state<divaria-busy={isLoading}aria-live="polite">
{isLoading ? 'Loading...' : content}
</div>
Tab / Shift+Tab — Move between focusable elements
Enter / Space — Activate buttons, links, checkboxes
Arrow keys — Navigate within composite widgets (tabs, menus, lists)
Escape — Close overlays (modals, menus, tooltips)
Home / End — Jump to first/last item in a list
Focus must stay inside a modal while it is open. Return focus to the trigger when it closes.
import { useRef, useEffect, useCallback } from'react';
functionuseFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// Store the element that had focus before the modal opened
previousFocusRef.current = document.activeElementasHTMLElement;
const container = containerRef.current;
if (!container) return;
// Focus the first focusable elementconst focusableSelector =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const firstFocusable = container.querySelector<HTMLElement>(focusableSelector);
firstFocusable?.focus();
functionhandleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
const focusableElements = container!.querySelectorAll<HTMLElement>(focusableSelector);
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} elseif (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
document.addEventListener('keydown', handleKeyDown);
return() => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus to the trigger element
previousFocusRef.current?.focus();
};
}, [isOpen]);
return containerRef;
}
// Usage in a modalfunctionModal({ isOpen, onClose, title, children }: ModalProps) {
const trapRef = useFocusTrap(isOpen);
if (!isOpen) returnnull;
return (
<divclassName="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<divclassName="absolute inset-0 bg-black/50"onClick={onClose}aria-hidden="true"
/>
{/* Dialog */}
<divref={trapRef}role="dialog"aria-modal="true"aria-labelledby="modal-title"className="relative z-10 w-full max-w-lg p-8 rounded-2xl bg-white shadow-xl
motion-reduce:transition-none"onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
}}
>
<h2id="modal-title"className="text-xl font-semibold">
{title}
</h2><divclassName="mt-4">{children}</div><buttononClick={onClose}aria-label="Close dialog"className="absolute top-4 right-4 p-2 rounded-lg transition-all duration-200
hover:bg-gray-100 focus-visible:ring-2 focus-visible:ring-offset-2"
><XIcon = />
);
}
Roving Tabindex
For composite widgets (tabs, toolbars, menus), one item is tabbable (tabindex="0") and the rest are tabindex="-1". Arrow keys move focus between items.
WCAG AA:
Normal text (<18px bold, <24px regular): 4.5:1 ratio
Large text (>=18px bold, >=24px regular): 3:1 ratio
UI components and graphical objects: 3:1 ratio
WCAG AAA:
Normal text: 7:1 ratio
Large text: 4.5:1 ratio
Color Must Not Be the Only Indicator
// BAD: only color distinguishes error state
<input className={hasError ? 'border-red-500' : 'border-gray-300'} />
// GOOD: color + icon + text<div><inputaria-invalid={hasError}aria-describedby={hasError ? 'email-error' :undefined}
className={`px-4py-3rounded-lgbordertransition-allduration-200focus-visible:ring-2focus-visible:ring-offset-2
${hasError
? 'border-red-500ring-red-100'
: 'border-gray-300focus-visible:ring-blue-500'
}`}
/>
{hasError && (
<pid="email-error"role="alert"className="mt-2 flex items-center gap-2 text-sm text-red-600"><AlertCircleIconclassName="w-4 h-4 shrink-0"aria-hidden="true" />
Please enter a valid email address
</p>
)}
</div>
KEYBOARD TESTING:
- [ ] Tab through entire page — all interactive elements reachable
- [ ] Shift+Tab moves backwards correctly
- [ ] Enter/Space activates buttons and links
- [ ] Escape closes modals, dropdowns, tooltips
- [ ] Arrow keys work inside tabs, menus, and comboboxes
- [ ] Focus is never trapped (except inside modals)
- [ ] Focus indicator is always visible on focused element
- [ ] Focus returns to trigger when modal/dropdown closes
SCREEN READER TESTING:
- [ ] VoiceOver (macOS): Cmd+F5 to enable, use VO+arrows to navigate
- [ ] NVDA (Windows): Free download, use browse mode and focus mode
- [ ] All images have descriptive alt text (or alt="" for decorative)
- [ ] Headings structure is logical (navigate with H key in NVDA/VO)
- [ ] Landmarks are present and labeled (navigate with D key in NVDA)
- [ ] Form fields announce their label, required state, and errors
- [ ] Dynamic content changes are announced via live regions
- [ ] Buttons and links announce their purpose
VISUAL TESTING:
- [ ] Zoom to 200% — no content clipped, no horizontal scroll
- [ ] Zoom to 400% — content still usable (WCAG 2.2)
- [ ] High contrast mode (Windows) — UI still functional
- [ ] prefers-reduced-motion respected — no unnecessary animation
- [ ] Color is not the only indicator of state (errors, success, links)
- [ ] All text meets contrast ratios (use browser DevTools audit)
TOOLS:
- axe DevTools (browser extension) — automated page scan
- Lighthouse (Chrome DevTools > Audits) — a11y score with recommendations
- Accessibility Insights (Microsoft) — guided manual + automated testing
- Colour Contrast Analyzer (TPGi) — eyedropper for contrast checking
- WAVE (WebAIM) — visual overlay of page accessibility issues
Best Practices
Start with semantic HTML — Correct elements (button, nav, main, label) provide free accessibility. ARIA is for custom widgets only.
Test with keyboard first — If you cannot complete every user flow with keyboard alone, the component is inaccessible.
Make focus visible — Always use focus-visible:ring-2 focus-visible:ring-offset-2 or equivalent. Never outline: none without replacement.
Announce dynamic changes — Use aria-live regions for status updates, toasts, loading states, and search results counts.
Label everything — Every interactive element needs an accessible name via <label>, aria-label, or aria-labelledby.
Do not disable zoom — Never set user-scalable=no or maximum-scale=1 in the viewport meta tag.
Use rem units — Pixels do not scale with user font size preferences. All sizing in rem.
Respect motion preferences — Provide prefers-reduced-motion alternatives with motion-reduce:transition-none.
Design for color blindness — Use icons, patterns, or text alongside color to convey meaning.
Test with real assistive technology — Automated tools catch ~30% of issues. Manual testing with VoiceOver and NVDA catches the rest.
Common Pitfalls
Pitfall
Impact
Fix
div and span for everything
No semantic meaning for AT
Use button, nav, main, section, ul/li
Missing alt text on images
Screen readers say "image" with no context
Add descriptive alt; use alt="" for decorative images
outline: none without replacement
Keyboard users cannot see focus
Use focus-visible:ring-2 instead of removing outlines
Positive tabindex values
Unpredictable tab order
Use only tabindex="0" or tabindex="-1"
Auto-playing video/audio
Disorienting, blocks screen reader
Never autoplay with sound; provide pause controls
Placeholder as label
Disappears on input, low contrast
Use visible <label> elements
Custom controls without roles
AT cannot identify widget type
Add appropriate ARIA roles, states, and properties
onClick on non-button elements
Not keyboard accessible
Use <button> or add role="button", tabindex="0", and onKeyDown
Missing error announcements
Screen reader users do not know form failed
Use role="alert" or aria-live="assertive" for errors
aria-hidden="true" on focusable elements
Focus enters hidden content, confusing AT
Remove from tab order with tabindex="-1" or do not hide
Time limits without extension
Users with motor/cognitive disabilities cannot complete tasks
Provide option to extend or disable time limits
Missing skip link
Keyboard users must tab through entire nav on every page