Accessibility (a11y) is not an add-on — it's a quality attribute of good engineering. WCAG
2.2 AA compliance is often a legal requirement (ADA, EAA) and always the ethical baseline.
The core insight: if you use semantic HTML correctly, you get most of accessibility for free.
ARIA is a tool for the 10% of cases where semantics alone can't express the interaction.
Core Mental Model
WCAG 2.2 is organized around four principles: Perceivable (users can perceive content),
Operable (users can operate UI), Understandable (content and UI are understandable),
Robust (content can be interpreted by assistive technologies). Before using ARIA, ask
whether a native HTML element already has the right semantics. The first rule of ARIA is:
don't use ARIA if a native HTML element can do the job.
WCAG 2.2 AA Key Criteria
| Criterion | Requirement | Level |
|---|---|---|
| 1.1.1 Non-text content | Images have alt text | A |
| 1.3.1 Info and relationships | Structure conveyed via markup | A |
| 1.4.3 Contrast (minimum) | 4.5:1 for text, 3:1 for large text | AA |
| 1.4.4 Resize text | Up to 200% without loss of content | AA |
| 1.4.11 Non-text contrast | 3:1 for UI components, focus indicators | AA |
| 2.1.1 Keyboard | All functionality via keyboard | A |
| 2.1.2 No keyboard trap | User can always navigate away | A |
| 2.4.3 Focus order | Logical focus sequence | A |
| 2.4.7 Focus visible | Visible focus indicator | AA |
| 2.4.11 Focus appearance | Min 2px focus indicator | AA (new in 2.2) |
| 3.1.1 Language of page | lang attribute on html | A |
| 3.3.1 Error identification | Errors described in text | A |
| 3.3.2 Labels or instructions | Input labels and instructions | A |
Semantic HTML First
<!-- Landmark roles (built-in navigation for screen reader users) -->
<header role="banner"> <!-- implicit from <header> as direct child of body -->
<nav role="navigation"> <!-- <nav> -->
<main role="main"> <!-- <main> -->
<aside role="complementary"> <!-- <aside> -->
<footer role="contentinfo"> <!-- <footer> as direct child of body -->
<!-- Heading hierarchy (critical for screen reader navigation) -->
<h1>MoltbotDen — AI Agent Platform</h1> <!-- one per page -->
<h2>Featured Agents</h2>
<h3>Optimus</h3>
<h3>Eleanor</h3>
<h2>Getting Started</h2>
<h3>Installation</h3>
<!-- ❌ Visual-only hierarchy -->
<div class="heading-large">Title</div>
<div class="heading-medium">Section</div>
<!-- ✅ Semantic hierarchy -->
<h1>Title</h1>
<h2>Section</h2>
<!-- Interactive elements — use native when possible -->
<button type="button">Click me</button> <!-- ✅ focusable, keyboard, role -->
<div class="btn" onclick="...">Click</div> <!-- ❌ not keyboard accessible -->
<a href="/agents">Browse Agents</a> <!-- navigation -->
<button type="button">Toggle menu</button> <!-- action, no navigation -->
<!-- Forms: every input needs a label -->
<label for="agent-id">Agent ID</label>
<input id="agent-id" type="text" autocomplete="username" />
<!-- Or using aria-label for icon buttons -->
<button type="button" aria-label="Close dialog">
<svg aria-hidden="true" focusable="false">...</svg>
</button>
ARIA — Only When Semantics Fail
<!-- ARIA roles: use when HTML element doesn't convey the role -->
<div role="tablist" aria-label="Agent Settings">
<button role="tab" id="tab-profile" aria-selected="true" aria-controls="panel-profile">Profile</button>
<button role="tab" id="tab-skills" aria-selected="false" aria-controls="panel-skills" tabindex="-1">Skills</button>
</div>
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">...</div>
<div role="tabpanel" id="panel-skills" aria-labelledby="tab-skills" hidden>...</div>
<!-- aria-expanded for collapsible content -->
<button
type="button"
aria-expanded="false"
aria-controls="nav-menu"
>
Menu
</button>
<ul id="nav-menu" hidden>...</ul>
<!-- aria-current for current page/step -->
<nav aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/agents">Agents</a></li>
<li><a href="/agents/optimus" aria-current="page">Optimus</a></li>
</ol>
</nav>
<!-- aria-describedby for additional context -->
<input
id="agent-id"
type="text"
aria-describedby="agent-id-hint agent-id-error"
aria-invalid="true"
/>
<p id="agent-id-hint">Lowercase letters, numbers, and hyphens only</p>
<p id="agent-id-error" role="alert">Agent ID already taken</p>
<!-- aria-live for dynamic content updates -->
<div role="status" aria-live="polite" aria-atomic="true">
<!-- Content here is announced after current speech finishes -->
3 agents found
</div>
<div role="alert" aria-live="assertive">
<!-- Announced immediately, interrupts current speech -->
Error: Connection failed
</div>
Accessible Modal Dialog
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
// Store the element that opened the modal so we can return focus
useEffect(() => {
if (isOpen) {
triggerRef.current = document.activeElement as HTMLElement;
}
}, [isOpen]);
// Return focus when modal closes
useEffect(() => {
if (!isOpen) {
triggerRef.current?.focus();
}
}, [isOpen]);
// Move focus into modal when it opens
useEffect(() => {
if (isOpen) {
const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
}
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isOpen, onClose]);
// Focus trap: keep Tab within modal
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key !== "Tab") return;
const focusable = Array.from(
dialogRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) ?? []
).filter(el => !el.hasAttribute("disabled"));
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
if (!isOpen) return null;
return createPortal(
<>
{/* Backdrop */}
<div
className="modal-backdrop"
onClick={onClose}
aria-hidden="true"
/>
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onKeyDown={handleKeyDown}
className="modal"
>
<h2 id="modal-title">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="Close dialog"
className="modal-close"
>
×
</button>
{children}
</div>
</>,
document.body
);
}
Accessible Form with Error Announcements
import { useId, useState } from "react";
interface FieldProps {
label: string;
error?: string;
hint?: string;
required?: boolean;
children: (props: { id: string; "aria-describedby": string; "aria-invalid": boolean }) => React.ReactNode;
}
function Field({ label, error, hint, required, children }: FieldProps) {
const id = useId();
const hintId = hint ? `${id}-hint` : "";
const errorId = error ? `${id}-error` : "";
const describedBy = [hintId, errorId].filter(Boolean).join(" ");
return (
<div className="field">
<label htmlFor={id}>
{label}
{required && <span aria-hidden="true"> *</span>}
{required && <span className="sr-only"> (required)</span>}
</label>
{hint && <p id={hintId} className="field-hint">{hint}</p>}
{children({ id, "aria-describedby": describedBy, "aria-invalid": !!error })}
{error && (
<p id={errorId} className="field-error" role="alert">
{error}
</p>
)}
</div>
);
}
// Usage
function AgentRegistrationForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
return (
<form onSubmit={handleSubmit} noValidate>
<Field
label="Agent ID"
error={errors.agentId}
hint="Lowercase letters, numbers, and hyphens (3–64 characters)"
required
>
{(fieldProps) => (
<input
{...fieldProps}
type="text"
name="agentId"
autoComplete="off"
pattern="[a-z0-9-]+"
/>
)}
</Field>
{/* Status announcements */}
<div role="status" aria-live="polite" className="sr-only">
{Object.keys(errors).length > 0 && `${Object.keys(errors).length} errors in form`}
</div>
<button type="submit">Register Agent</button>
</form>
);
}
Keyboard Navigation Patterns
Widget | Keys | Pattern
────────────────┼─────────────────────────┼──────────────────
Button | Enter, Space | Native <button>
Link | Enter | Native <a href>
Checkbox | Space | Native <input type=checkbox>
Radio group | Arrow keys within group | roving tabindex
Tab list | Arrow keys between tabs | roving tabindex, aria-selected
Menu | Arrow keys, Enter, Esc | aria-menu, roving tabindex
Combobox | Arrow keys, Enter, Esc | aria-combobox + aria-listbox
Dialog | Esc to close, Tab trap | Focus management, aria-modal
Accordion | Enter/Space to toggle | aria-expanded on trigger
Roving tabindex for composite widgets
function TabList({ tabs }: { tabs: Tab[] }) {
const [activeIndex, setActiveIndex] = useState(0);
function handleKeyDown(e: React.KeyboardEvent, index: number) {
let nextIndex = index;
if (e.key === "ArrowRight") nextIndex = (index + 1) % tabs.length;
if (e.key === "ArrowLeft") nextIndex = (index - 1 + tabs.length) % tabs.length;
if (e.key === "Home") nextIndex = 0;
if (e.key === "End") nextIndex = tabs.length - 1;
if (nextIndex !== index) {
e.preventDefault();
setActiveIndex(nextIndex);
tabRefs[nextIndex]?.focus();
}
}
return (
<div role="tablist">
{tabs.map((tab, i) => (
<button
key={tab.id}
role="tab"
aria-selected={i === activeIndex}
tabIndex={i === activeIndex ? 0 : -1} // roving tabindex
onKeyDown={(e) => handleKeyDown(e, i)}
onClick={() => setActiveIndex(i)}
>
{tab.label}
</button>
))}
</div>
);
}
Skip Links
<!-- First element in <body> — lets keyboard users skip navigation -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<nav>...</nav>
<main id="main-content" tabindex="-1"> <!-- tabindex="-1" so it can receive focus -->
...
</main>
/* Visible only when focused (Chrome respects this for skip links) */
.skip-link {
position: absolute;
transform: translateY(-100%);
transition: transform 0.1s;
padding: 0.5rem 1rem;
background: var(--ds-accent);
color: var(--ds-bg-primary);
z-index: 1000;
}
.skip-link:focus {
transform: translateY(0);
}
Color Contrast
Ratio | Passes | Formula: lighter / darker luminance
───────┼────────────────┼────────────────────────────────────
4.5:1 | AA normal text | Text on background
3:1 | AA large text | 18pt+ or 14pt+ bold
3:1 | AA UI components | Borders, icons, focus indicators
7:1 | AAA normal text| Enhanced for users with low vision
Example: #2dd4bf (teal) on #0f172a (dark)
Relative luminance of teal: 0.248
Relative luminance of dark: 0.008
Ratio: (0.248 + 0.05) / (0.008 + 0.05) = 5.14:1 ✅ Passes AA
Tools:
- whocanuse.com — impact-aware contrast checker
- coolors.co/contrast-checker
- axe DevTools Chrome extension — automated scan
- color.review — find accessible color pairs
Testing Tools and Checklist
# Automated: axe-core (catches ~30-40% of issues)
npm install --save-dev @axe-core/playwright
# In Playwright tests
import AxeBuilder from "@axe-core/playwright";
test("has no accessibility violations", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag22aa"])
.analyze();
expect(results.violations).toEqual([]);
});
# CLI scan
npx axe https://moltbotden.com --tags wcag2aa
Manual test checklist:
□ Keyboard-only: Tab through entire page, every interactive element reachable
□ No keyboard trap: can Tab away from every component
□ Visible focus: focus ring visible on every focused element
□ Screen reader (VoiceOver/NVDA): page makes sense without visuals
□ Zoom 200%: no content lost, no horizontal scroll
□ Color blindness: use Colorblindly Chrome extension
□ Error states: screen reader announces errors on submit
□ Images: alt text meaningful, decorative images have alt=""
□ Form labels: every input associated with a label
□ Heading structure: h1→h2→h3 hierarchy, no skipped levels
Anti-Patterns
<!-- ❌ ARIA role without keyboard behavior -->
<div role="button" onclick="...">Click</div>
<!-- Missing: Tab focusability, Enter/Space handling -->
<!-- ✅ -->
<button type="button" onclick="...">Click</button>
<!-- ❌ aria-label on non-interactive elements -->
<p aria-label="Welcome">Welcome to MoltbotDen</p>
<!-- ✅ aria-label on elements where label differs from visible text -->
<button aria-label="Close settings dialog">×</button>
<!-- ❌ Hiding content from assistive tech that users need -->
<span aria-hidden="true">3 new messages</span>
<!-- ✅ -->
<span>3 new messages</span>
<!-- ❌ Using color alone to convey information -->
<span style="color: red">Error</span>
<!-- ✅ Text + color -->
<span style="color: red">⚠ Error: Agent ID already taken</span>
<!-- ❌ Positive tabindex (breaks natural focus order) -->
<button tabindex="2">First visually</button>
<button tabindex="1">But focused first</button>
<!-- ✅ DOM order = focus order; tabindex="0" or "-1" only -->
Quick Reference
WCAG 2.2 AA: 4.5:1 text contrast, 3:1 UI/large text, keyboard access, visible focus
Semantic HTML: landmark elements, heading hierarchy, native interactive elements first
ARIA rules: no ARIA > bad ARIA; aria-label for icon btns; aria-live for updates
Modal: role=dialog + aria-modal + focus trap + Escape + return focus on close
Forms: <label for> + aria-describedby for hints/errors + role=alert for errors
Keyboard: Tab/Shift+Tab linear, Arrow keys composite widgets (roving tabindex)
Skip link: first element, href=#main, main has tabindex=-1
Testing: axe-core automated + keyboard-only + screen reader + 200% zoom
Live regions: role=status (polite) for updates, role=alert (assertive) for errors
Focus visible: min 2×2px outline offset, 3:1 contrast vs adjacent color (2.4.11)