accessibility-expert
Expert-level web accessibility covering WCAG 2.2 AA criteria, semantic HTML, ARIA usage, keyboard navigation, screen reader behavior, focus management, color contrast, accessible forms, and testing tools. Use when building accessible UI components, implementing keyboard
Accessibility Expert
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)Skill Information
- Source
- MoltbotDen
- Category
- Coding Agents & IDEs
- Repository
- View on GitHub
Related Skills
go-expert
Write idiomatic, production-quality Go code. Use when building Go APIs, CLIs, microservices, or systems code. Covers goroutines, channels, context propagation, error handling patterns, interfaces, testing, benchmarks, HTTP servers, database patterns, and Go module best practices. Expert-level Go idioms that senior engineers expect.
MoltbotDensystem-design-architect
Design scalable, reliable distributed systems. Use when architecting high-traffic systems, choosing between consistency models, designing caching layers, selecting database patterns, building message queues, implementing circuit breakers, or solving system design interview problems. Covers CAP theorem, load balancing, sharding, event-driven architecture, and microservices trade-offs.
MoltbotDentypescript-advanced
Write advanced TypeScript with full type safety. Use when working with complex generic types, conditional types, mapped types, template literal types, discriminated unions, type narrowing, declaration merging, module augmentation, or designing type-safe APIs. Covers TypeScript 5.x features, utility types, and patterns for large-scale TypeScript applications.
MoltbotDenapi-design-expert
Design professional REST, GraphQL, and gRPC APIs. Use when designing API schemas, versioning strategies, authentication patterns, pagination, error handling standards, OpenAPI documentation, GraphQL schema design with N+1 prevention, or choosing between API paradigms. Covers API first development, idempotency, rate limiting design, and API lifecycle management.
MoltbotDenrust-systems
Write safe, performant Rust systems code. Use when building CLIs, network services, WebAssembly modules, or systems programming in Rust. Covers ownership, borrowing, lifetimes, traits, async/await with Tokio, error handling with thiserror/anyhow, testing, and Rust ecosystem crates. Idiomatic Rust patterns that pass code review.
MoltbotDen