Web Accessibility (a11y)
Accessibility is not a feature you add at the end. It is a quality of software that determines whether your product works for everyone or only for people who hold the mouse with their right hand, have 20/20 vision, and never use a keyboard. One billion people worldwide live with a disability. Building accessible interfaces is not charity — it is engineering competence.
Web Accessibility (a11y)
Accessibility is not a feature you add at the end. It is a quality of software that determines whether your product works for everyone or only for people who hold the mouse with their right hand, have 20/20 vision, and never use a keyboard. One billion people worldwide live with a disability. Building accessible interfaces is not charity — it is engineering competence.
Principles
1. WCAG 2.2 Overview
The Web Content Accessibility Guidelines (WCAG) are the international standard for web accessibility. WCAG defines three conformance levels:
| Level | Description | Target? |
|---|---|---|
| A | Bare minimum. Removes the most severe barriers. | Mandatory baseline |
| AA | Addresses the most common barriers for the widest range of users. | Yes — this is the standard target. Most laws reference AA. |
| AAA | Highest level. Not always achievable for all content, but ideal for critical flows. | Aspirational for key pages |
WCAG 2.2 (published October 2023) added criteria focused on authentication, dragging alternatives, and target size minimums. Always reference the latest version.
2. The Four POUR Principles
Every WCAG criterion maps to one of four principles:
Perceivable — Users must be able to perceive the content.
- Provide text alternatives for non-text content (alt text, captions, transcripts).
- Ensure sufficient color contrast.
- Do not rely on color alone to convey information.
- Support text resizing up to 200% without loss of content or functionality.
Operable — Users must be able to operate the interface.
- All functionality must be available via keyboard.
- Provide enough time to read and interact with content.
- Do not design content that causes seizures (no flashing more than 3 times per second).
- Provide clear navigation mechanisms and skip links.
Understandable — Users must be able to understand the content and interface behavior.
- Use clear, plain language.
- Make form behavior predictable (no unexpected context changes on input).
- Provide input assistance: labels, instructions, error identification, and suggestions.
Robust — Content must be robust enough to work with current and future technologies.
- Use valid, semantic HTML.
- Ensure compatibility with assistive technologies (screen readers, switch devices, voice control).
- Name, role, and value of all UI components must be programmatically determinable.
3. Semantic HTML as the Foundation
The single most impactful accessibility practice is using the correct HTML elements. Native HTML elements carry built-in semantics, keyboard behavior, and ARIA roles that custom <div>-based widgets must manually replicate.
| Instead of... | Use... | Why |
|---|---|---|
<div onclick> | <button> | Keyboard focusable, Enter/Space activation, implicit role="button" |
<div class="link"> | <a href> | Keyboard focusable, screen reader announces as link, right-click/open-in-new-tab works |
<div class="input"> | <input> | Native form participation, validation, autocomplete, screen reader label association |
<div class="header"> | <header>, <h1>-<h6> | Screen reader navigation by landmarks and heading levels |
<div class="list"> | <ul>, <ol>, <li> | Screen reader announces "list, 5 items" and allows item-by-item navigation |
<div class="table"> | <table>, <thead>, <th> | Screen reader announces row/column headers when navigating cells |
<div class="nav"> | <nav> | Screen reader landmark navigation; announced as "navigation" |
Rule: If a native HTML element does what you need, use it. Never recreate browser functionality with JavaScript when HTML provides it for free.
4. ARIA: When and How to Use It
ARIA (Accessible Rich Internet Applications) extends HTML semantics for complex widgets that have no native HTML equivalent: tabs, accordions, tree views, comboboxes, live regions.
The five rules of ARIA:
- Do not use ARIA if native HTML works. A
<button>is always better than<div role="button" tabindex="0">. - Do not change native semantics. Do not put
role="button"on a<h2>. If it must be clickable, nest a<button>inside the heading. - All interactive ARIA controls must be keyboard operable. Adding
role="tab"without arrow key navigation is a lie to assistive technology. - Do not use
role="presentation"oraria-hidden="true"on focusable elements. Hiding something from the accessibility tree while it remains focusable creates a trap. - All interactive elements must have an accessible name. Use visible labels,
aria-label, oraria-labelledby.
Common ARIA patterns:
| Widget | Key ARIA attributes |
|---|---|
| Dialog/Modal | role="dialog", aria-modal="true", aria-labelledby |
| Tabs | role="tablist", role="tab", role="tabpanel", aria-selected, aria-controls |
| Accordion | aria-expanded, aria-controls, heading + button pattern |
| Combobox | role="combobox", aria-expanded, aria-activedescendant, role="listbox" |
| Live region | aria-live="polite" or aria-live="assertive", role="status", role="alert" |
| Progress | role="progressbar", aria-valuenow, aria-valuemin, aria-valuemax |
5. Keyboard Navigation
Every interactive element must be reachable and operable via keyboard alone. Many users cannot use a mouse — including people with motor disabilities, power users, and anyone with a broken trackpad.
Core keyboard patterns:
- Tab / Shift+Tab moves focus between interactive elements in DOM order.
- Enter / Space activates buttons and links.
- Arrow keys navigate within composite widgets (tabs, menus, radio groups, listboxes).
- Escape closes modals, popups, and dropdowns.
- Home / End jumps to first/last item in a list or menu.
Focus management rules:
- Tab order must follow visual order. If CSS reorders elements visually, update the DOM order or use
tabindexcarefully. - Never use
tabindexgreater than 0. It breaks natural tab order. Usetabindex="0"to make non-interactive elements focusable, andtabindex="-1"for programmatic focus only. - Focus must be visible. Never remove the focus outline (
outline: none) without providing an equally visible replacement. The:focus-visiblepseudo-class allows you to show focus rings only for keyboard users. - Focus trapping in modals. When a modal is open, Tab must cycle only within the modal. Focus must move to the modal on open and return to the trigger element on close.
- Manage focus on route changes in SPAs. When the page content changes without a full reload, move focus to the new content or announce the change via a live region.
6. Color Contrast Requirements
WCAG defines minimum contrast ratios between text and its background:
| Element | Minimum contrast ratio (AA) | Enhanced (AAA) |
|---|---|---|
| Normal text (< 18pt or < 14pt bold) | 4.5:1 | 7:1 |
| Large text (>= 18pt or >= 14pt bold) | 3:1 | 4.5:1 |
| UI components and graphical objects | 3:1 | N/A |
| Focus indicators | 3:1 against adjacent colors | N/A |
Practical guidance:
- Test every text/background combination with a contrast checker (browser DevTools, WebAIM Contrast Checker, or Figma plugins).
- Do not rely on color alone to convey information. A red error input must also have an icon, a text message, or a border change.
- Placeholder text in inputs is notoriously low contrast. If it carries important information, use a visible label instead.
- Disabled elements are exempt from contrast requirements, but consider keeping them readable anyway.
7. Screen Reader Testing and Common Patterns
Building for screen readers means building with semantic HTML and proper ARIA. Testing confirms that your intentions match reality.
How to test:
| Platform | Screen reader | How to activate |
|---|---|---|
| macOS / iOS | VoiceOver | Cmd+F5 (Mac), triple-click home/side button (iOS) |
| Windows | NVDA (free) | Download from nvaccess.org |
| Windows | JAWS (paid) | Industry standard in enterprise environments |
| Android | TalkBack | Settings > Accessibility > TalkBack |
| Chrome | Built-in accessibility inspector | DevTools > Elements > Accessibility tab |
What to test:
- Can you navigate the entire page using only the Tab key?
- Are all interactive elements announced with their name, role, and state?
- Do headings form a logical hierarchy (h1 > h2 > h3)?
- Are images announced with meaningful alt text (or hidden from the tree if decorative)?
- Are form fields associated with their labels?
- Are error messages announced when they appear?
- Can you open, use, and close modal dialogs?
- Are dynamic content updates announced via live regions?
8. Forms Accessibility
Forms are where accessibility failures cause the most real-world harm. An inaccessible form prevents users from signing up, checking out, or getting help.
Requirements:
- Every input must have a visible
<label>associated viafor/idor by wrapping the input. Placeholder text is not a label — it disappears on input and is often low contrast. - Required fields must be indicated both visually and programmatically (
requiredattribute oraria-required="true"). - Error messages must be associated with their input via
aria-describedbyand announced to screen readers usingaria-live="polite"orrole="alert". - Group related fields with
<fieldset>and<legend>(e.g., radio button groups, address sections). - Provide autocomplete attributes (
autocomplete="email",autocomplete="given-name") to help users fill forms faster and to support password managers. - Do not clear the form on error. Preserve user input and focus the first field with an error.
9. Images and Alt Text Best Practices
Every <img> element must have an alt attribute. The content of that attribute depends on the image's purpose:
| Image type | Alt text approach | Example |
|---|---|---|
| Informative | Describe the content and function | alt="Bar chart showing Q4 revenue growth of 23%" |
| Decorative | Empty alt attribute | alt="" (with role="presentation" optional) |
| Functional (inside a link/button) | Describe the action, not the image | alt="Go to homepage" (for a logo link) |
| Complex (charts, diagrams) | Brief alt + longer description nearby or via aria-describedby | alt="Q4 revenue chart" with a data table below |
| Text in image | Reproduce the text in alt | alt="50% off all items this weekend" |
Rules:
- Never write
alt="image",alt="photo", oralt="icon". These are meaningless. - Do not start with "Image of..." or "Picture of..." — screen readers already announce it as an image.
- If an image is purely decorative (background pattern, visual flourish), use
alt=""so screen readers skip it entirely. - For CSS background images that carry meaning, provide a text alternative in the HTML or use
role="img"witharia-label.
10. Legal Requirements
Accessibility is increasingly a legal obligation, not just a best practice.
| Regulation | Jurisdiction | Scope |
|---|---|---|
| ADA (Americans with Disabilities Act) | United States | Applies to "places of public accommodation" — courts have consistently ruled this includes websites. No explicit technical standard, but WCAG 2.1 AA is the de facto benchmark. |
| Section 508 | United States | Applies to federal agencies and federally funded organizations. Requires WCAG 2.0 AA conformance. |
| EAA (European Accessibility Act) | European Union | In effect since June 28, 2025. Requires digital products and services sold in the EU to meet accessibility standards (EN 301 549, which references WCAG 2.1 AA). |
| AODA | Ontario, Canada | Requires WCAG 2.0 AA for public sector and large organizations. |
| EN 301 549 | European Union | The harmonized European standard for ICT accessibility. References WCAG 2.1 AA for web content. |
Key takeaway: If you target WCAG 2.2 AA, you meet the requirements of virtually every current accessibility law globally. The cost of retrofitting accessibility after a lawsuit or complaint is orders of magnitude higher than building it in from the start.
11. Cognitive Accessibility
WCAG 2.2 includes success criteria that address cognitive load, not just visual and motor accessibility. These affect users with cognitive disabilities, learning disabilities, attention disorders, and also benefit all users under stress or distraction.
Key WCAG 2.2 cognitive criteria:
- 3.3.7 Redundant Entry (A): Do not require users to re-enter information they have already provided in the same session. Pre-fill from previous steps.
- 3.3.8 Accessible Authentication (AA): Do not rely on cognitive function tests (CAPTCHAs, puzzles, memory-based security questions) as the sole authentication mechanism. Allow copy-paste into password fields. Support password managers. Provide alternatives to cognitive tests.
- 3.2.6 Consistent Help (A): If a help mechanism is available (chat, phone, FAQ link), place it in a consistent location across all pages.
General cognitive accessibility principles:
- Minimize cognitive load. Break complex tasks into steps. Show one thing at a time. Use progressive disclosure.
- Be predictable. Consistent navigation, consistent layout, consistent interaction patterns. Unexpected behavior increases cognitive burden.
- Provide clear instructions. Do not rely on the user inferring what to do. Label every form field. Describe every step. Use placeholder text as a hint, not a replacement for labels.
- Allow ample time. Do not impose time limits on form completion or session duration without providing extensions. WCAG 2.2.1 requires timeout warnings with the option to extend.
- Support error recovery. Clear error messages, easy correction, and the ability to undo.
12. Touch Accessibility (WCAG 2.5.8 Target Size)
WCAG 2.5.8 (Level AA, new in WCAG 2.2) requires that touch targets be at least 24x24 CSS pixels, with sufficient spacing from adjacent targets that the total interactive area is at least 24px in every direction. The older 2.5.5 (Level AAA) requires 44x44px.
Practical guidance:
- 48x48px minimum is the recommended target for all interactive elements (aligns with Android/iOS guidelines). Meeting 48px satisfies both WCAG 2.5.8 (24px) and 2.5.5 (44px) with margin.
- 8px minimum spacing between adjacent touch targets.
- Padding expands the target. A 16px icon with 16px padding is a 48px target. The visual size can be smaller than the touch target.
- Exceptions: Inline links within body text and controls whose size is determined by the user agent (native
<select>, etc.) are exempt.
/* Ensure all interactive elements meet touch target minimums */
button,
a,
input[type="checkbox"],
input[type="radio"],
[role="button"],
[role="tab"],
[role="menuitem"] {
min-height: 48px;
min-width: 48px;
}
/* For small visual elements, use padding to expand touch area */
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px; /* visual size */
height: 24px;
padding: 12px; /* expands to 48px touch target */
box-sizing: content-box;
}13. Reduced Motion
Users can enable prefers-reduced-motion: reduce in their OS settings to indicate discomfort with animated content. Respecting this preference is a WCAG 2.3.3 requirement and a core accessibility obligation.
Minimum implementation:
- Include a global CSS reset that removes transitions and animations when the preference is active.
- Keep opacity fades and color changes — these are generally safe.
- Remove transforms, position shifts, and scale changes.
- Never disable all visual feedback — users still need to know the UI is responding.
See ../Animation-Motion/animation-motion.md for detailed implementation patterns and Framer Motion / CSS integration.
LLM Instructions
When an AI assistant is asked to audit, build, or review UI code for accessibility, follow these directives:
Audit Pages for Accessibility
- Check all images for meaningful alt text (or
alt=""for decorative images). - Verify every form input has an associated
<label>element (viafor/idor wrapping). - Check color contrast ratios: 4.5:1 for normal text, 3:1 for large text and UI components.
- Verify heading hierarchy is logical (
h1→h2→h3, no skipped levels). - Confirm all interactive elements are reachable via keyboard (Tab) and activatable (Enter/Space).
- Check for focus visibility — every focused element must have a visible indicator.
- Verify that ARIA attributes are used correctly (valid roles, required properties present, states updating dynamically).
- Check that dynamic content changes are announced via
aria-liveregions. - Test that modals trap focus and return focus to the trigger on close.
- Verify skip navigation links exist and work.
Output a checklist with pass/fail status, severity (critical/major/minor), element reference, and specific fix instructions for each failure.
Write Semantic HTML
- Use the most specific native HTML element for every purpose:
<button>for actions,<a>for navigation,<nav>for navigation landmarks,<main>for primary content,<header>/<footer>for page/section headers and footers,<aside>for complementary content. - Structure headings hierarchically. Each page should have exactly one
<h1>. - Use
<ul>/<ol>for lists. Use<table>with<thead>,<th>, andscopeattributes for data tables. - Use
<fieldset>and<legend>to group related form controls. - Never use
<div>or<span>for interactive elements.
Implement ARIA Correctly
- Follow the first rule of ARIA: do not use ARIA if a native HTML element provides the semantics and behavior you need.
- For custom widgets (tabs, accordions, comboboxes, dialogs), implement the full ARIA Authoring Practices pattern including all required roles, properties, states, and keyboard interactions.
- Always provide an accessible name for interactive elements: visible label,
aria-label, oraria-labelledby. - Use
aria-describedbyto associate help text, error messages, and additional descriptions. - Use
aria-live="polite"for non-urgent dynamic updates andaria-live="assertive"orrole="alert"for errors and critical notifications. - Update
aria-expanded,aria-selected,aria-checked, andaria-presseddynamically as state changes.
Handle Focus Management
- When opening a modal/dialog, move focus to the first focusable element inside (or the dialog itself if it has a label).
- Trap focus within the modal: Tab from the last focusable element cycles to the first, Shift+Tab from the first cycles to the last.
- On closing a modal, return focus to the element that triggered it.
- On SPA route changes, move focus to the new page's main heading or main content area.
- Never use
tabindex> 0. Usetabindex="0"for custom focusable elements andtabindex="-1"for elements that receive programmatic focus only. - Ensure focus order matches visual reading order.
Create Accessible Form Patterns
- Every input must have a visible, persistent
<label>(not just a placeholder). - Mark required fields with the
requiredattribute and indicate them visually (asterisk or "required" text). - Display inline error messages below the relevant field. Associate errors with their input using
aria-describedby. - Use an
aria-live="polite"region orrole="alert"to announce error summaries to screen readers. - On form submission failure, move focus to the first field with an error or to an error summary at the top of the form.
- Use
autocompleteattributes for common fields (name,email,tel,address,cc-number). - Group related inputs (radio buttons, checkboxes, address fields) in a
<fieldset>with a descriptive<legend>.
Examples
Example 1: Accessible Modal/Dialog Component
// components/ui/dialog.tsx
"use client";
import React, { useId, useEffect, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
interface DialogProps {
/** Whether the dialog is currently open */
open: boolean;
/** Called when the dialog should close (Escape key, backdrop click, close button) */
onClose: () => void;
/** Accessible title for the dialog — required */
title: string;
/** Optional description displayed below the title */
description?: string;
/** Dialog content */
children: React.ReactNode;
}
export function Dialog({ open, onClose, title, description, children }: DialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
/* --------------------------------------------------
FOCUS TRAP: Tab cycles within the dialog only.
Shift+Tab from first element goes to last.
Tab from last element goes to first.
-------------------------------------------------- */
const getFocusableElements = useCallback(() => {
if (!dialogRef.current) return [];
const selector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(", ");
return Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(selector)
);
}, []);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
/* Close on Escape */
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}
/* Trap focus on Tab */
if (e.key === "Tab") {
const focusable = getFocusableElements();
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
},
[onClose, getFocusableElements]
);
/* --------------------------------------------------
On open: store previously focused element,
move focus into the dialog.
On close: restore focus to the trigger element.
-------------------------------------------------- */
useEffect(() => {
if (open) {
previouslyFocusedRef.current = document.activeElement as HTMLElement;
document.addEventListener("keydown", handleKeyDown);
// Focus the dialog container (or the first focusable element)
requestAnimationFrame(() => {
const focusable = getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
} else {
dialogRef.current?.focus();
}
});
// Prevent background scrolling
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
// Restore focus to the element that opened the dialog
if (previouslyFocusedRef.current) {
previouslyFocusedRef.current.focus();
}
};
}, [open, handleKeyDown, getFocusableElements]);
const id = useId();
if (!open) return null;
const titleId = `${id}-title`;
const descriptionId = description ? `${id}-description` : undefined;
return createPortal(
<>
{/* Backdrop — clicking it closes the dialog */}
<div
className="fixed inset-0 z-50 bg-black/50"
aria-hidden="true"
onClick={onClose}
/>
{/* Dialog panel */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-xl"
>
{/* Title — required for screen readers */}
<h2 id={titleId} className="text-lg font-semibold text-neutral-900">
{title}
</h2>
{/* Description — optional */}
{description && (
<p id={descriptionId} className="mt-1 text-sm text-neutral-600">
{description}
</p>
)}
{/* Content */}
<div className="mt-4">{children}</div>
{/* Close button */}
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 rounded-sm p-1 text-neutral-400 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label="Close dialog"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
</>,
document.body
);
}Usage:
function DeleteConfirmation() {
const [open, setOpen] = React.useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Delete account</button>
<Dialog
open={open}
onClose={() => setOpen(false)}
title="Delete account?"
description="This action is permanent and cannot be undone."
>
<div className="flex justify-end gap-3 mt-6">
<button onClick={() => setOpen(false)}>Cancel</button>
<button onClick={handleDelete} className="bg-red-600 text-white rounded px-4 py-2">
Yes, delete
</button>
</div>
</Dialog>
</>
);
}Example 2: Accessible Form with Labels, Errors, and ARIA Live Regions
// components/contact-form.tsx
"use client";
import React, { useState, useRef } from "react";
interface FormErrors {
name?: string;
email?: string;
message?: string;
}
export function ContactForm() {
const [errors, setErrors] = useState<FormErrors>({});
const [submitted, setSubmitted] = useState(false);
const errorSummaryRef = useRef<HTMLDivElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const emailRef = useRef<HTMLInputElement>(null);
const messageRef = useRef<HTMLTextAreaElement>(null);
function validate(formData: FormData): FormErrors {
const errs: FormErrors = {};
if (!formData.get("name")) errs.name = "Name is required.";
const email = formData.get("email") as string;
if (!email) {
errs.email = "Email is required.";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errs.email = "Please enter a valid email address.";
}
if (!formData.get("message")) errs.message = "Message is required.";
return errs;
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const validationErrors = validate(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
setSubmitted(false);
// Focus the error summary so screen readers announce it
requestAnimationFrame(() => errorSummaryRef.current?.focus());
return;
}
setErrors({});
setSubmitted(true);
// Submit to your API here
}
const hasErrors = Object.keys(errors).length > 0;
return (
<form onSubmit={handleSubmit} noValidate aria-label="Contact form">
{/* -----------------------------------------------
Error summary — announced to screen readers.
Placed at the top of the form so keyboard users
encounter it before the fields.
----------------------------------------------- */}
{hasErrors && (
<div
ref={errorSummaryRef}
role="alert"
tabIndex={-1}
className="mb-6 rounded-md border border-red-300 bg-red-50 p-4"
>
<h2 className="text-sm font-semibold text-red-800">
Please fix the following errors:
</h2>
<ul className="mt-2 list-disc pl-5 text-sm text-red-700">
{errors.name && (
<li>
<a href="#name" className="underline">
{errors.name}
</a>
</li>
)}
{errors.email && (
<li>
<a href="#email" className="underline">
{errors.email}
</a>
</li>
)}
{errors.message && (
<li>
<a href="#message" className="underline">
{errors.message}
</a>
</li>
)}
</ul>
</div>
)}
{/* ---- Name field ---- */}
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-neutral-900">
Name <span aria-hidden="true">*</span>
</label>
<input
ref={nameRef}
id="name"
name="name"
type="text"
required
autoComplete="name"
aria-required="true"
aria-invalid={errors.name ? "true" : undefined}
aria-describedby={errors.name ? "name-error" : undefined}
className={`mt-1 block w-full rounded-md border px-3 py-2 text-sm ${
errors.name
? "border-red-500 focus:ring-red-500"
: "border-neutral-300 focus:ring-blue-500"
} focus:outline-none focus:ring-2`}
/>
{errors.name && (
<p id="name-error" className="mt-1 text-sm text-red-600">
{errors.name}
</p>
)}
</div>
{/* ---- Email field ---- */}
<div className="mb-4">
<label htmlFor="email" className="block text-sm font-medium text-neutral-900">
Email <span aria-hidden="true">*</span>
</label>
<input
ref={emailRef}
id="email"
name="email"
type="email"
required
autoComplete="email"
aria-required="true"
aria-invalid={errors.email ? "true" : undefined}
aria-describedby={errors.email ? "email-error email-hint" : "email-hint"}
className={`mt-1 block w-full rounded-md border px-3 py-2 text-sm ${
errors.email
? "border-red-500 focus:ring-red-500"
: "border-neutral-300 focus:ring-blue-500"
} focus:outline-none focus:ring-2`}
/>
<p id="email-hint" className="mt-1 text-xs text-neutral-500">
We will never share your email.
</p>
{errors.email && (
<p id="email-error" className="mt-1 text-sm text-red-600">
{errors.email}
</p>
)}
</div>
{/* ---- Message field ---- */}
<div className="mb-6">
<label htmlFor="message" className="block text-sm font-medium text-neutral-900">
Message <span aria-hidden="true">*</span>
</label>
<textarea
ref={messageRef}
id="message"
name="message"
rows={4}
required
aria-required="true"
aria-invalid={errors.message ? "true" : undefined}
aria-describedby={errors.message ? "message-error" : undefined}
className={`mt-1 block w-full rounded-md border px-3 py-2 text-sm ${
errors.message
? "border-red-500 focus:ring-red-500"
: "border-neutral-300 focus:ring-blue-500"
} focus:outline-none focus:ring-2`}
/>
{errors.message && (
<p id="message-error" className="mt-1 text-sm text-red-600">
{errors.message}
</p>
)}
</div>
{/* ---- Submit ---- */}
<button
type="submit"
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
Send message
</button>
{/* ---- Success announcement ---- */}
{submitted && (
<p className="mt-4 text-sm text-green-700" role="status" aria-live="polite">
Your message has been sent successfully. We will respond within 24 hours.
</p>
)}
</form>
);
}Example 3: Skip Navigation Link Implementation
<!--
Skip navigation allows keyboard and screen reader users to bypass
repetitive navigation and jump directly to the main content.
It should be the FIRST focusable element in the DOM.
It is visually hidden until focused, then slides into view.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Accessible Page</title>
<style>
/* Skip link: visually hidden by default, visible on focus */
.skip-link {
position: absolute;
top: -100%;
left: 16px;
z-index: 9999;
padding: 8px 16px;
background-color: #1e40af;
color: #ffffff;
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
border-radius: 0 0 6px 6px;
transition: top 0.2s ease;
}
.skip-link:focus {
top: 0;
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
</style>
</head>
<body>
<!-- Skip link — first element in the DOM -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<!-- Navigation -->
<header>
<nav aria-label="Primary navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
</header>
<!-- Main content — the skip link target -->
<main id="main-content" tabindex="-1">
<!--
tabindex="-1" allows the element to receive focus programmatically
(via the skip link) without being part of the natural tab order.
-->
<h1>Welcome to our accessible website</h1>
<p>This content is immediately accessible via the skip link.</p>
</main>
<footer>
<nav aria-label="Footer navigation">
<ul>
<li><a href="/privacy">Privacy Policy</a></li>
<li><a href="/terms">Terms of Service</a></li>
</ul>
</nav>
</footer>
</body>
</html>Notes:
- The skip link is the very first focusable element so pressing Tab once reveals it.
aria-labelon both<nav>elements distinguishes them for screen reader users ("Primary navigation" vs. "Footer navigation").tabindex="-1"on<main>allows it to receive focus from the skip link anchor without appearing in the natural tab order.- The skip link only becomes visible when it receives keyboard focus (
:focus), keeping the visual design clean for mouse users.
Example 4: Accessibility Audit Checklist
Use this checklist to audit any web page. Each item maps to WCAG 2.2 AA success criteria.
## Accessibility Audit Checklist
### Perceivable
- [ ] All images have appropriate alt text (informative images described, decorative images have alt="")
- [ ] Video content has captions; audio content has transcripts
- [ ] Color contrast meets 4.5:1 for normal text and 3:1 for large text (WCAG 1.4.3)
- [ ] Information is not conveyed by color alone (WCAG 1.4.1)
- [ ] Text can be resized to 200% without loss of content or functionality (WCAG 1.4.4)
- [ ] Content reflows at 320px width without horizontal scrolling (WCAG 1.4.10)
- [ ] Non-text contrast: UI components and graphical objects meet 3:1 (WCAG 1.4.11)
### Operable
- [ ] All functionality is available via keyboard (WCAG 2.1.1)
- [ ] No keyboard traps — users can Tab into and out of every component (WCAG 2.1.2)
- [ ] Skip navigation link is present and functional (WCAG 2.4.1)
- [ ] Page has a descriptive <title> (WCAG 2.4.2)
- [ ] Focus order is logical and follows visual layout (WCAG 2.4.3)
- [ ] Link purpose is clear from the link text (no "click here") (WCAG 2.4.4)
- [ ] Focus indicator is visible on all interactive elements (WCAG 2.4.7)
- [ ] Touch targets are at least 24x24 CSS pixels (WCAG 2.5.8)
- [ ] Hover/focus content (tooltips, dropdowns) is dismissible, hoverable, and persistent (WCAG 1.4.13)
### Understandable
- [ ] Page language is set via <html lang="en"> (WCAG 3.1.1)
- [ ] Form inputs have visible, associated labels (WCAG 3.3.2)
- [ ] Error messages identify the field and describe the error (WCAG 3.3.1)
- [ ] Error suggestions are provided when possible (WCAG 3.3.3)
- [ ] No unexpected context changes on focus or input (WCAG 3.2.1, 3.2.2)
- [ ] Consistent navigation and identification across pages (WCAG 3.2.3, 3.2.4)
### Robust
- [ ] HTML validates without major errors (good practice; WCAG 4.1.1 was removed in WCAG 2.2)
- [ ] All interactive elements have accessible names (WCAG 4.1.2)
- [ ] ARIA roles, states, and properties are valid and complete (WCAG 4.1.2)
- [ ] Status messages are announced without receiving focus (WCAG 4.1.3)
- [ ] Custom widgets follow ARIA Authoring Practices patterns
### Screen Reader Testing
- [ ] Tested with VoiceOver (macOS/iOS) or NVDA/JAWS (Windows)
- [ ] Headings form a logical, sequential hierarchy
- [ ] Landmarks (main, nav, header, footer) are present and labeled
- [ ] Form fields announce their label, required state, and any errors
- [ ] Dynamic content updates are announced via live regions
- [ ] Modal dialogs announce their title and trap focus correctly
### Automated Testing
- [ ] axe-core or Lighthouse accessibility audit passes with 0 critical/serious issues
- [ ] ESLint eslint-plugin-jsx-a11y passes with 0 errors
- [ ] Pa11y or similar CI tool integrated into the build pipelineExample 5: axe-core Integration Test (Playwright)
Automated accessibility testing integrated into your E2E test suite.
// tests/accessibility.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
// Test every critical page for accessibility violations
const pages = [
{ name: "Home", path: "/" },
{ name: "Dashboard", path: "/dashboard" },
{ name: "Settings", path: "/settings" },
{ name: "Login", path: "/login" },
{ name: "Signup", path: "/signup" },
];
for (const page of pages) {
test(`${page.name} page should have no accessibility violations`, async ({
page: playwrightPage,
}) => {
await playwrightPage.goto(page.path);
// Wait for page content to load
await playwrightPage.waitForLoadState("networkidle");
const results = await new AxeBuilder({ page: playwrightPage })
.withTags(["wcag2a", "wcag2aa", "wcag22aa"]) // WCAG 2.2 AA
.analyze();
// Report violations with helpful output
const violations = results.violations.map((v) => ({
id: v.id,
impact: v.impact,
description: v.description,
nodes: v.nodes.length,
help: v.helpUrl,
}));
if (violations.length > 0) {
console.table(violations);
}
expect(results.violations).toEqual([]);
});
// Test dark mode separately — contrast failures often differ
test(`${page.name} page (dark mode) should have no a11y violations`, async ({
page: playwrightPage,
}) => {
// Set dark mode preference
await playwrightPage.emulateMedia({ colorScheme: "dark" });
await playwrightPage.goto(page.path);
await playwrightPage.waitForLoadState("networkidle");
const results = await new AxeBuilder({ page: playwrightPage })
.withTags(["wcag2a", "wcag2aa", "wcag22aa"])
.analyze();
expect(results.violations).toEqual([]);
});
}
// Test specific interactive states
test("Modal dialog should be accessible when open", async ({ page }) => {
await page.goto("/dashboard");
await page.click('button:has-text("New project")');
// Wait for modal to appear
await page.waitForSelector('[role="dialog"]');
const results = await new AxeBuilder({ page })
.include('[role="dialog"]') // Scope to just the modal
.withTags(["wcag2a", "wcag2aa"])
.analyze();
expect(results.violations).toEqual([]);
});Key decisions:
- Tests every critical page route, not just the homepage. Accessibility issues often vary by page.
- Separate dark mode tests catch contrast violations that only appear in dark theme.
withTags(["wcag2a", "wcag2aa", "wcag22aa"])targets WCAG 2.2 AA compliance.- Modal test scopes axe to just the dialog with
.include()for focused, relevant results. console.table(violations)provides readable CI output when violations are found.
Example 6: Accessible Tab Component (React)
A fully accessible tab component following the WAI-ARIA Tabs pattern.
import { useState, useRef, useCallback } from "react";
type Tab = {
id: string;
label: string;
content: React.ReactNode;
};
interface TabsProps {
tabs: Tab[];
defaultTab?: string;
label: string; // Accessible label for the tablist
}
export function Tabs({ tabs, defaultTab, label }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const setTabRef = useCallback(
(id: string) => (el: HTMLButtonElement | null) => {
if (el) tabRefs.current.set(id, el);
else tabRefs.current.delete(id);
},
[]
);
function handleKeyDown(e: React.KeyboardEvent) {
const currentIndex = tabs.findIndex((t) => t.id === activeTab);
let nextIndex: number | null = null;
switch (e.key) {
case "ArrowRight":
nextIndex = (currentIndex + 1) % tabs.length;
break;
case "ArrowLeft":
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
const nextTab = tabs[nextIndex];
setActiveTab(nextTab.id);
tabRefs.current.get(nextTab.id)?.focus();
}
return (
<div>
{/* Tab list */}
<div
role="tablist"
aria-label={label}
onKeyDown={handleKeyDown}
className="tab-list"
>
{tabs.map((tab) => (
<button
key={tab.id}
ref={setTabRef(tab.id)}
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
className={`tab-button ${activeTab === tab.id ? "active" : ""}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab panels */}
{tabs.map((tab) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== tab.id}
tabIndex={0}
className="tab-panel"
>
{tab.content}
</div>
))}
</div>
);
}.tab-list {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
.tab-button {
padding: 0.75rem 1rem;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--color-text-muted, #6b7280);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
min-height: 44px;
transition: color 150ms ease, border-color 150ms ease;
}
.tab-button:hover {
color: var(--color-text, #1f2937);
}
.tab-button.active {
color: var(--color-primary, #2563eb);
border-bottom-color: var(--color-primary, #2563eb);
}
.tab-button:focus-visible {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -2px;
border-radius: 2px;
}
.tab-panel {
padding: 1.5rem 0;
}
.tab-panel:focus-visible {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: 2px;
border-radius: 4px;
}Why this works:
- Follows the WAI-ARIA Tabs pattern exactly:
role="tablist",role="tab",role="tabpanel". - Arrow keys move between tabs (roving tabindex pattern). Only the active tab is in the tab order (
tabIndex={0}); inactive tabs havetabIndex={-1}. - Home/End jump to first/last tab.
aria-selectedindicates the active tab.aria-controlslinks each tab to its panel.aria-labelledbylinks each panel back to its tab.- Tab panels have
tabIndex={0}so keyboard users can Tab into the panel content. - Focus indicator uses
:focus-visibleto avoid showing on mouse click. - Minimum 44px height for touch accessibility.
Common Mistakes
- Using
<div>and<span>for interactive elements. A<div onclick>is not a button. It has no keyboard support, no implicit role, and no focus management. Use<button>. - Removing focus outlines without a replacement.
*:focus { outline: none }in your reset stylesheet makes your site unusable for keyboard users. Use:focus-visibleto show outlines only for keyboard navigation. - Using ARIA to fix what semantic HTML would solve.
<div role="button" tabindex="0" onkeydown={handleEnter}>is four lines of code to badly replicate<button>. Use the native element. - Placeholder text as the only label. Placeholders disappear when users start typing, are often low contrast, and are not reliably read by all screen readers. Use a visible
<label>. - Missing alt text on images. Every
<img>must have analtattribute. Informative images need descriptive text. Decorative images needalt="". No image should have a missingaltattribute. - Relying on color alone to communicate state. A red border on an error field is invisible to colorblind users. Add an icon, text message, or other non-color indicator.
- Not testing with a keyboard. Unplug your mouse and try to complete your most critical user flow. If you get stuck, your keyboard users are stuck too.
- Setting
tabindexto a positive number.tabindex="5"forces the element to the front of the tab order and breaks the natural flow for every user. Usetabindex="0"ortabindex="-1"only. - Hiding content from screen readers that sighted users can see. Using
aria-hidden="true"on visible, meaningful content removes it from the accessibility tree. Only hide truly decorative or redundant elements. - Ignoring focus management in single-page applications. When a route change replaces the page content without a full reload, screen readers do not know the page changed. Move focus to the new content or announce the change via a live region.
- Treating accessibility as a separate task at the end of a project. Accessibility is not a line item on a QA checklist. It is a constraint that informs design, component architecture, and HTML structure from day one. Retrofitting is always harder and more expensive.
- Only running automated tests. Automated tools (axe, Lighthouse) catch approximately 30-40% of accessibility issues. The rest require manual testing with keyboards, screen readers, and real users with disabilities.
See also: Design-Systems | UX-Patterns | Typography-Color | Animation-Motion | Mobile-First | Dark-Mode
Last reviewed: 2026-02
By Ryan Lind, Assisted by Claude Code and Google Gemini.
Brand Identity & Style Guide
Your brand is not a logo and a hex code. It is a system of visual and verbal decisions that make every screen feel like it belongs to the same product. This guide teaches you how to define that system so an AI assistant can generate on-brand UI from the first prompt.
Responsive Design
Breakpoints, fluid layouts, container queries, and building interfaces that work on every screen.