WCAG 2.2 Accessibility: A Developer Guide
A practical guide to WCAG 2.2 compliance: the four POUR principles, ARIA, keyboard navigation, accessible forms and modals, tested with real code examples.
Overview
Web accessibility (a11y) means building sites that people with disabilities can actually use — perceiving content, navigating it, and completing tasks whether they browse with a mouse, a keyboard, a screen reader, or a braille display. WCAG 2.2 is the current international standard, organized around four principles: Perceivable, Operable, Understandable, and Robust (POUR).
This guide walks the criteria that matter most in day-to-day frontend work — semantic HTML, ARIA, keyboard navigation, forms, modals, and contrast — with code you can adapt directly. It’s also a legal topic: ADA, Section 508, EN 301 549, and the European Accessibility Act all reference WCAG, so “we’ll get to it later” is a risk decision, not a backlog item. When you’re ready to verify a whole site, the Accessibility Audit Checklist turns what this guide teaches into a documented audit.
When to Use
- You’re building public-facing websites or applications.
- You need to meet legal requirements (ADA, EAA, Section 508, EN 301 549).
- You want to improve usability for all users, including screen reader and keyboard users.
- You’re preparing for or remediating an accessibility audit.
- You’re building a design system or component library that others will inherit.
The Four Principles (POUR)
| Principle | What It Means | Key Criteria |
|---|---|---|
| Perceivable | Information must be presentable in ways users can perceive | Alt text, color contrast, resizable text |
| Operable | Interface components must be operable by all users | Keyboard navigation, focus indicators, timing |
| Understandable | Information and operation must be understandable | Readable text, predictable behavior, error prevention |
| Robust | Content must work with current and future assistive tech | Valid HTML, ARIA roles, name-role-value |
The principles aren’t a checklist to complete once — they’re a lens for every component you build. A date picker that looks perfect but can’t be reached by keyboard fails Operable. A form that announces errors only by turning them red fails Perceivable. Most real-world failures touch exactly one principle, which makes the framework useful for triaging bugs as well as audits.
Semantic HTML First
The cheapest accessibility is the kind you get for free. Native elements carry built-in semantics, keyboard support, and focus behavior; <div> carries nothing. Screen readers announce <nav> as navigation and <button> as a button — a <div class="nav"> announces as nothing.
<!-- Bad: div soup — screen readers see nothing -->
<div class="header">
<div class="nav">
<div class="link" onclick="navigate('/')">Home</div>
</div>
</div>
<!-- Good: semantic elements — screen readers understand structure -->
<header>
<nav aria-label="Main navigation">
<a href="/">Home</a>
</nav>
</header>
<!-- Semantic elements provide built-in accessibility -->
<main>
<article>
<header>
<h1>Article Title</h1>
<time datetime="2026-07-05">July 5, 2026</time>
</header>
<section>
<h2>Section Heading</h2>
<p>Content...</p>
</section>
<aside>
<h2>Related</h2>
<ul>
<li><a href="/related-1">Related Article 1</a></li>
</ul>
</aside>
</article>
</main>
Headings deserve the same discipline: <h1> through <h6> are the document’s table of contents for assistive technology. Jumping from <h2> to <h5> reads to a screen-reader user like a book skipping chapters — keep the hierarchy sequential and use CSS for visual size.
ARIA: When HTML Isn’t Enough
ARIA (Accessible Rich Internet Applications) adds semantic information to elements that need it — but the first rule of ARIA is don’t use it if a native element exists. A <div role="button"> needs tabindex, keyboard handlers, and ARIA attributes to do half of what <button> does natively.
<!-- Bad: recreating a button -->
<div role="button" tabindex="0" onclick="submit()">Submit</div>
<!-- Good: the element that already is a button -->
<button onclick="submit()">Submit</button>
ARIA is for the gap: custom widgets (tabs, trees, comboboxes), live regions, and state that native HTML can’t express:
<!-- aria-label: when there's no visible text -->
<button aria-label="Close dialog" onclick="closeDialog()">
<svg aria-hidden="true"><use href="#icon-close" /></svg>
</button>
<!-- aria-labelledby: when visible text labels the element -->
<input type="search" aria-labelledby="search-label" />
<span id="search-label">Search products</span>
<!-- aria-describedby: additional description -->
<input type="password" aria-describedby="password-hint" />
<p id="password-hint">Must be at least 12 characters with a number and symbol.</p>
<!-- aria-live: announce dynamic content changes -->
<div aria-live="polite" id="status"></div>
<!-- aria-expanded: toggle state -->
<button aria-expanded="false" aria-controls="menu" onclick="toggleMenu()">
Menu
</button>
<ul id="menu" hidden>
<li><a href="/settings">Settings</a></li>
<li><a href="/logout">Logout</a></li>
</ul>
<!-- aria-hidden: hide decorative content from screen readers -->
<svg aria-hidden="true" class="icon"><path d="..." /></svg>
Two traps to avoid: ARIA adds semantics, never behavior — role="button" doesn’t make a div clickable, you still wire the keyboard and click handlers yourself. And wrong ARIA is worse than none: a mislabeled widget actively misleads the user instead of merely saying nothing.
How Accessible Names Are Computed
Every interactive element needs a name the screen reader can announce — “the accessible name.” The browser computes it from a priority list that’s worth knowing because it explains most “why does this button say nothing” bugs:
aria-labelledby— wins over everything; points at visible text elsewhere.aria-label— next priority; overrides the element’s own content.- Content or associated
<label>— link text, button text,alt,<label for>— the fallback. title— last resort; don’t rely on it.
<!-- Announces "Search products" — aria-labelledby wins -->
<input type="search" aria-label="Site search" aria-labelledby="search-label" />
<span id="search-label">Search products</span>
<!-- Announces "Site search" — aria-label beats content -->
<button aria-label="Site search">Click here</button>
The practical consequence: aria-label replaces the visible text for screen readers, it doesn’t augment it. aria-label="Site search" on a button reading “Submit” announces “Site search” — great when intentional, confusing when copied blindly. And a <label> wrapped around an input (or linked with for/id) is almost always the right answer for forms; reach for aria-label only when no visible text can do the job.
The Accessible Name vs. Visible Text Rule (2.5.3)
One criterion people trip on: whatever an aria-label says must contain the visible text. A button showing “Delete” labeled aria-label="Remove item" announces “Remove item” — a voice-control user saying “click Delete” can’t activate it. Keep the accessible name’s first words matching the visible label and this never bites you.
This naming discipline scales with component count: a page of well-named controls reads to a screen-reader user like a labeled control panel; a page of “click here” links and unlabeled icons reads like a wall of buttons. It’s the same test as contrast — invisible until you listen to your own UI.
Perceivable
Text Alternatives (1.1.1)
Every meaningful image needs a text alternative; decorative images need an empty one.
<!-- Good: descriptive alt text -->
<img src="chart.png" alt="Bar chart showing Q1-Q4 revenue growth from 2M to 5M" />
<!-- Good: decorative image hidden from screen readers -->
<img src="decoration.png" alt="" />
<!-- Bad: missing or useless alt text -->
<img src="chart.png" />
<img src="chart.png" alt="image" />
The distinction that matters: informative images get alt text that conveys the point of the image (what does the chart show?), while purely decorative ones get alt="" so the screen reader skips them. An icon next to a text label is decorative; a chart in a report is content.
Color Contrast (1.4.3, 1.4.11)
Text must have sufficient contrast against its background — the single most common WCAG failure on the web.
| Level | Normal Text | Large Text | UI Components |
|---|---|---|---|
| AA | 4.5:1 | 3:1 | 3:1 |
| AAA | 7:1 | 4.5:1 | — |
/* Good contrast (AA compliant on white) */
.text-primary { color: #1e293b; } /* 14.7:1 */
.text-secondary { color: #475569; } /* 7.6:1 */
.text-muted { color: #64748b; } /* 4.6:1 */
/* Bad contrast (fails AA) */
.text-faint { color: #94a3b8; } /* 2.9:1 — fails for normal text */
/* Focus indicator must be 3:1 against adjacent colors */
button:focus-visible {
outline: 3px solid #2563eb; /* blue on white: 5.2:1 */
outline-offset: 2px;
}
And contrast isn’t only text: icon buttons, form borders, and focus indicators all need 3:1 against adjacent colors under 1.4.11. “Gray on white” is where this usually breaks — #999 is 2.8:1 and fails; #767676 is 4.5:1 and passes.
Time-Based Media (1.2.x)
Video and audio need alternatives for the people who can’t hear or see them:
<!-- Captions for prerecorded video (1.2.2) -->
<video controls>
<source src="demo.mp4" type="video/mp4" />
<track kind="captions" src="captions.vtt" srclang="en" label="English" default />
</video>
<!-- Audio description or full transcript for informational video (1.2.5) -->
<details>
<summary>Transcript</summary>
<p>The demo opens on the dashboard showing four metrics...</p>
</details>
Captions and transcripts are cheap to produce now — auto-captioning gets you 90% of the way, human review fixes the names and jargon. An untranscribed five-minute demo video is five minutes of content that doesn’t exist for a deaf user.
Resizable Text (1.4.4) and Reflow (1.4.10)
Text must survive 200% zoom and content must reflow at 320px without horizontal scrolling.
/* Good: relative units respect user settings */
body { font-size: 100%; }
h1 { font-size: 2rem; }
/* Bad: fixed pixels that break zoom */
body { font-size: 16px; }
Operable
Keyboard Accessible (2.1.1)
All functionality must work from a keyboard — no mouse required, ever.
<!-- Good: native elements are keyboard accessible -->
<button onclick="submit()">Submit</button>
<a href="/next">Next Page</a>
<!-- Bad: div pretending to be a button -->
<div class="btn" onclick="submit()">Submit</div>
<!-- Good: custom component with keyboard support -->
<div role="button" tabindex="0"
onclick="submit()"
onkeydown="if(event.key==='Enter'||event.key===' ') submit()">
Submit
</div>
Focus Visible (2.4.7)
Keyboard focus must be visible at all times — it’s how keyboard users know where they sit on the page.
/* Never remove focus indicators without replacement */
*:focus {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* Custom focus style for interactive elements */
button:focus-visible,
a:focus-visible {
box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.4);
}
Bypass Blocks (2.4.1)
Provide a way to skip repetitive content — keyboard users shouldn’t tab through thirty nav links on every page.
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #2563eb;
color: white;
padding: 8px 16px;
z-index: 100;
transition: top 0.2s;
}
.skip-link:focus { top: 0; }
<a href="#main" class="skip-link">Skip to main content</a>
<nav>...</nav>
<main id="main"><h1>Page content</h1></main>
Live Regions and Dynamic Content (4.1.3)
When content changes without a page reload — form results, toast notifications, search counts, SPA route changes — screen-reader users don’t see it unless something announces it. aria-live regions are that something.
<!-- Polite: announced when the user is idle — status updates, counts -->
<div aria-live="polite" id="status"></div>
<!-- Assertive: interrupts immediately — errors, urgent alerts -->
<div role="alert" id="error-banner"></div>
// Announce a dynamic update
function showStatus(message: string) {
const status = document.getElementById("status");
status!.textContent = message;
}
// SPA route changes: announce the new page
function announceRoute(title: string) {
document.title = title;
const announcer = document.getElementById("route-announcer");
announcer!.textContent = `Navigated to ${title}`;
// And move focus to the new page's h1 or main landmark
}
Two rules keep live regions usable: prefer polite unless it’s genuinely urgent (assertive announcements cut off whatever the screen reader was saying), and put the region in the initial HTML — regions injected after load aren’t reliably observed. Route announcements plus focus management on navigation are the difference between an SPA that narrates like a website and one where the user loses their place on every link click.
Motion and Animation (2.2.2, 2.3.3)
Auto-playing, flashing, or parallax content needs to respect the user. Flashing content above three flashes per second is a seizure risk — a hard requirement, not a preference — and prefers-reduced-motion lets users opt out of decorative motion:
/* Respect the user's motion preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
The full override above is the blunt version; the refined approach keeps functional animation (loading indicators, progress) and only kills decorative motion (parallax, auto-carousels, entrance animations).
Roving Tabindex for Custom Widgets
Composite widgets like tabs and toolbars keep one item in the tab order and move focus with arrow keys — the pattern that makes component keyboards usable.
// Tab interface: only one tab is in the tab order at a time
class TabList {
private tabs: HTMLElement[] = [];
private activeIndex = 0;
constructor(container: HTMLElement) {
this.tabs = Array.from(container.querySelectorAll('[role="tab"]'));
this.tabs.forEach((tab, index) => {
tab.addEventListener("keydown", this.handleKeyDown.bind(this));
tab.addEventListener("click", () => this.activate(index));
});
this.updateTabindex();
}
private updateTabindex() {
this.tabs.forEach((tab, index) => {
tab.tabIndex = index === this.activeIndex ? 0 : -1;
tab.setAttribute("aria-selected", String(index === this.activeIndex));
});
}
private activate(index: number) {
this.activeIndex = index;
this.updateTabindex();
this.tabs[index].focus();
}
private handleKeyDown(e: KeyboardEvent) {
switch (e.key) {
case "ArrowRight":
e.preventDefault();
this.activate((this.activeIndex + 1) % this.tabs.length);
break;
case "ArrowLeft":
e.preventDefault();
this.activate((this.activeIndex - 1 + this.tabs.length) % this.tabs.length);
break;
case "Home":
e.preventDefault();
this.activate(0);
break;
case "End":
e.preventDefault();
this.activate(this.tabs.length - 1);
break;
}
}
}
Understandable
Readable Text (3.1.5) and Language (3.1.1)
Content should be readable and marked with its language so screen readers pronounce it correctly.
<!-- Declare the page language -->
<html lang="en">
<!-- Good: clear, simple language -->
<p>Enter your email to reset your password.</p>
<!-- Bad: jargon and complexity -->
<p>Input your registered electronic mail address to initiate credential recovery protocol.</p>
Predictable Behavior (3.2.x)
Interfaces shouldn’t change context without warning — no surprise submits on focus, no nav that reorders between pages. Consistent navigation and predictable responses are criteria, not preferences.
Error Prevention (3.3.4)
Prevent errors on legal, financial, or data-modifying submissions — confirmation steps and review screens.
<!-- Good: confirmation for destructive actions -->
<form onsubmit="return confirm('Delete this account permanently?')">
<button type="submit">Delete Account</button>
</form>
Robust
Valid HTML and Name, Role, Value (4.1.2)
Assistive technology parses your markup — valid HTML and correct ARIA roles are how components announce their role and purpose to a screen reader.
<!-- Good: native semantic element with accessible name -->
<nav aria-label="Main navigation">
<ul>
<li><a href="/" aria-current="page">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
<!-- Good: ARIA for custom components -->
<div role="tablist">
<button role="tab" aria-selected="true" id="tab-1">Tab 1</button>
<button role="tab" aria-selected="false" id="tab-2">Tab 2</button>
</div>
<div role="tabpanel" aria-labelledby="tab-1">Panel content</div>
Accessible Forms
Forms are where accessibility failures cost conversions — every unlabeled field is a user lost at the last step. The complete pattern: a visible <label> for every input, required fields marked in text (not just color), grouped controls in <fieldset>/<legend>, and errors announced programmatically.
<form>
<!-- Every input has a label -->
<div>
<label for="name">Full Name</label>
<input type="text" id="name" name="name" required autocomplete="name" />
</div>
<!-- Required fields marked with text, not just color -->
<div>
<label for="email">
Email <span aria-hidden="true" class="required">*</span>
<span class="sr-only">(required)</span>
</label>
<input
type="email"
id="email"
name="email"
required
autocomplete="email"
aria-describedby="email-error"
aria-invalid="false"
/>
<p id="email-error" class="error" role="alert"></p>
</div>
<!-- Grouped controls use fieldset/legend -->
<fieldset>
<legend>Notification Preferences</legend>
<label><input type="checkbox" name="notify" value="email" /> Email</label>
<label><input type="checkbox" name="notify" value="sms" /> SMS</label>
</fieldset>
<button type="submit">Create Account</button>
</form>
Error handling needs two pieces: aria-invalid on the failing field and an announcement channel (role="alert" or aria-live) so the error reaches the user, not just the screen.
// Accessible form validation
function validateField(field: HTMLInputElement): boolean {
const errorEl = document.getElementById(`${field.id}-error`);
const isValid = field.checkValidity();
field.setAttribute("aria-invalid", String(!isValid));
if (!isValid && errorEl) {
errorEl.textContent = field.validationMessage;
errorEl.style.display = "block";
} else if (errorEl) {
errorEl.textContent = "";
errorEl.style.display = "none";
}
return isValid;
}
Accessible Modal Dialog
Modals are the classic keyboard trap. An accessible one focuses into itself on open, keeps focus inside while open (the “trap”), closes on Escape, and returns focus to the trigger on close. The native <dialog> element handles most of this.
<dialog aria-labelledby="modal-title" aria-describedby="modal-desc">
<h2 id="modal-title">Confirm Deletion</h2>
<p id="modal-desc">Are you sure you want to delete this item? This action cannot be undone.</p>
<div class="actions">
<button onclick="modal.close()">Cancel</button>
<button onclick="confirmDelete()">Delete</button>
</div>
</dialog>
class AccessibleModal {
private dialog: HTMLDialogElement;
private previouslyFocused: HTMLElement | null = null;
constructor(dialog: HTMLDialogElement) {
this.dialog = dialog;
dialog.addEventListener("close", this.onClose.bind(this));
dialog.addEventListener("keydown", this.onKeyDown.bind(this));
}
open() {
this.previouslyFocused = document.activeElement as HTMLElement;
this.dialog.showModal();
const firstFocusable = this.dialog.querySelector(
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
) as HTMLElement;
firstFocusable?.focus();
}
close() {
this.dialog.close();
}
private onClose() {
// Return focus to the element that opened the modal
this.previouslyFocused?.focus();
}
private onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
this.close();
return;
}
// Focus trap: keep focus within the dialog
if (e.key !== "Tab") return;
const focusables = Array.from(
this.dialog.querySelectorAll<HTMLElement>(
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
)
).filter((el) => !el.hasAttribute("disabled"));
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
Screen-Reader-Only Content
Sometimes the visual design can’t carry information the screen reader needs — icon buttons, item counts, status text. The .sr-only pattern hides content visually while keeping it in the accessibility tree.
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Show visually when focused (e.g., skip link) */
.sr-only-focusable:focus {
position: static;
width: auto;
height: auto;
overflow: visible;
clip: auto;
white-space: normal;
}
<!-- Icon button with an accessible name -->
<button>
<span aria-hidden="true">🗑</span>
<span class="sr-only">Delete item</span>
</button>
<!-- Position context in a list -->
<ul>
<li>Item 1 <span class="sr-only">of 5</span></li>
<li>Item 2 <span class="sr-only">of 5</span></li>
</ul>
Testing Accessibility
The three-layer test from the audit checklist applies here too: automated tools triage, keyboard/manual testing finds interaction failures, screen-reader testing finds semantic ones.
| Tool | Purpose |
|---|---|
| axe DevTools | Browser extension for automated checks |
| Lighthouse | Built-in Chrome accessibility audit |
| axe-core + Playwright | Automated checks in CI |
| NVDA / JAWS | Screen reader testing (Windows) |
| VoiceOver | Screen reader testing (macOS) |
| Keyboard only | Manual navigation test |
| WAVE | WebAIM accessibility evaluation tool |
Automated tools catch about 30-40% of issues — the mechanical ones. The rest need the human passes: keyboard through every flow, screen-reader through the critical ones, 200% zoom and 320px reflow for the visual criteria. The Accessibility Audit Checklist structures that process when you need it to be repeatable and auditable, and the Browser Support Matrix Template records which browser/screen-reader pairings you actually covered — NVDA+Firefox and VoiceOver+Safari expose different failures than NVDA+Chrome.
For continuous enforcement, axe-core in the test suite catches regressions at PR time — the mechanical 40% becomes self-enforcing and the audit can focus on what machines can’t judge:
test("homepage passes WCAG", async ({ page }) => {
await page.goto("https://shop.example.com");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(results.violations).toEqual([]);
});
What’s New in WCAG 2.2
Nine criteria joined in 2.2 — worth knowing because audits written for 2.1 miss them:
- 2.4.11 Focus Not Obscured — focused elements can’t be fully hidden behind sticky headers or overlays.
- 2.4.13 Focus Appearance (AAA) — a minimum size and contrast for the focus indicator itself.
- 2.5.7 Dragging Movements — drag-and-drop needs a single-pointer alternative.
- 2.5.8 Target Size — pointer targets at least 24×24 CSS pixels (with spacing exceptions).
- 3.2.6 Consistent Help — help mechanisms in the same spot across pages.
- 3.3.7 Redundant Entry — don’t ask users to re-enter data you already have.
- 3.3.8 Accessible Authentication — no cognitive puzzles (memory tests, CAPTCHA-style transcription) to log in.
The pattern in the additions: less tolerance for interactions that assume mouse, memory, or unobstructed view. If your last audit was against 2.1, these are the rows to add.
Accessibility in the Development Lifecycle
The cheapest place to fix accessibility is the moment it’s decided. Each stage of the pipeline has a different cost and catches a different category of failure.
Design. Contrast, focus states, and reading order are design decisions before they’re code. A mockup reviewed for accessibility — is there a focus style? does this gray pass 4.5:1? can the flow be operated without a mouse? — catches failures that are nearly free to change in Figma and expensive to change after a design system ships them to forty pages. The tokens are where it pays off most: fix the muted-text token once, fix it everywhere.
Component. Building a component? Test it before it ships — one keyboard pass and one screen-reader pass on the story page. A date picker that’s accessible in the component library is accessible everywhere it’s used; the reverse is also true. Component-level testing is where roving tabindex, focus traps, and live regions get proven once instead of re-failing on every page.
Pull request. axe-core in CI catches the mechanical regressions — missing alt, unassociated labels, invalid ARIA — at the point of change, when the developer still has context. It can’t judge meaning (that’s what the checklist’s human passes are for), but it makes the mechanical 40% self-enforcing.
Audit. The quarterly or pre-release audit verifies the whole: flows, interactions, the parts automation can’t see. The Accessibility Audit Checklist exists for this stage — the guide teaches how to build it right; the checklist verifies you did.
The pattern across all four: accessibility is a property of the code, not a phase you bolt on. Teams that treat it as an audit-time activity spend every cycle remediating; teams that build it into the lifecycle spend the same hours preventing.
One last framing worth keeping: accessibility work looks like a cost center until you count who it serves. Roughly one in four adults lives with some disability; everyone else ages into one eventually. The curb-cut effect is real — captions built for deaf users get used on muted phones, keyboard navigation built for motor impairments gets used by power users, contrast built for low vision gets used in sunlight. You rarely know which accessibility feature your next user needs; you just know they’ll need one.
Common Accessibility Patterns Worth Knowing
A handful of patterns cover most of what real apps need. Beyond the form, modal, and live-region code above, the frequent flyers:
- Skip links for bypassing nav (shown above under Operable).
aria-current="page"on the active nav item — screen readers announce “current page” instead of just the link text.- Loading states: a spinner that announces nothing is a dead end for screen readers — pair it with an
aria-livestatus (“Loading results…”). - Data tables:
<th scope="col">headers so screen readers can announce the column each cell belongs to; complex tables getheaders/idwiring. - Icon-only buttons: always an
aria-labelor.sr-onlytext — “button, button, button” is what an unlabeled icon row sounds like. - Toast notifications:
role="status"witharia-live="polite"so the message reaches the user; critical alerts getrole="alert". - External links: warn when a link opens a new window (
target="_blank"+ visually-hidden “(opens in new tab)”) — the surprise context switch fails 3.2.x for some users.
Most of these are three lines of markup. The expensive part is remembering they exist — which is what the component library is for: encode them once in the shared components and every page inherits the fix.
Common Mistakes
- Removing focus outlines:
* { outline: none }makes keyboard navigation impossible — always provide a visible replacement. - Using divs for buttons:
<div onclick>isn’t keyboard accessible; use<button>. - Missing form labels: inputs without labels are unnamed to screen readers — placeholders don’t count.
- Color-only error indication: red text alone is invisible to colorblind users; add icons and text.
- No alt text on images: screen readers read the filename; descriptive
altoralt=""for decorative ones. - Heading hierarchy skips: jumping
<h1>to<h4>breaks the document outline; don’t skip levels. - ARIA added without testing: wrong roles mislead users — verify with an actual screen reader.
- Autoplaying media without controls: respect
prefers-reduced-motionand pause controls. - Infinite scroll without a skip mechanism: provide pagination or search as alternatives.
- Overlays as a compliance fix: accessibility widgets don’t fix underlying markup — fix the code.
Further Reading
- WCAG 2.2 Recommendation (W3C) — the standard itself
- Understanding WCAG 2.2 — intent and techniques per criterion
- ARIA Authoring Practices Guide (APG) — patterns for accessible widgets
- WebAIM Screen Reader Survey — what your users actually run
- axe-core rules — what automated tools check
- Companion resources — copy-paste snippets: skip link, sr-only, accessible form/modal, tabs, axe CI spec
Frequently Asked Questions
What WCAG level should I target?
Level AA is the standard for most legal requirements — ADA, Section 508, EN 301 549, and the EAA all reference AA conformance. Level AAA is aspirational for critical content and rarely achievable sitewide.
What is ARIA?
Accessible Rich Internet Applications — a set of HTML attributes that add accessibility information to custom widgets. ARIA doesn't add behavior, it only communicates semantics to assistive technologies. Use it when HTML's built-in semantics aren't enough; native elements are always preferable.
Do I need to test with actual screen readers?
Yes. Automated tools catch roughly a third of issues — the mechanical ones. Focus order logic, announcement quality, and reading order only reveal themselves with NVDA, VoiceOver, or JAWS running against the real page.
What's the difference between WCAG 2.1 and 2.2?
WCAG 2.2 adds nine success criteria — mostly around focus appearance, dragging, target size, and authentication — and removes 4.1.1 Parsing. If you comply with 2.1, the nine new criteria are your delta for 2.2 compliance.
How do I test accessibility?
Three layers: automated tools (axe, Lighthouse) for mechanical checks, keyboard-only testing for interaction, and screen-reader passes for semantics. Then 200% zoom and 320px width for the visual criteria. The audit checklist structures it end-to-end.
Can I use an overlay widget instead of fixing the code?
No. Overlays like AccessiBe or UserWay inject JavaScript that claims to fix accessibility dynamically — they don't repair underlying markup, can interfere with screen readers, and courts haven't accepted them as compliance. The fix is in the code.
What color contrast ratio do I need?
WCAG AA requires 4.5:1 for normal text, 3:1 for large text (18pt+ or 14pt bold), and 3:1 for UI components and focus indicators. AAA raises text to 7:1/4.5:1.
Related Resources
Accessibility Audit Checklist
A WCAG 2.2 compliance checklist covering perceivable, operable, understandable, and robust criteria with testing tools and remediation steps.
GuideProgressive Web Apps (PWA) — Complete Guide
A thorough guide to building Progressive Web Apps: service workers, offline support, Web App Manifest, push notifications, and installability.
GuideWeb Components — Custom Elements, Shadow DOM & Templates
A practical guide to Web Components: creating reusable custom elements, encapsulating styles with Shadow DOM, and composing with HTML templates.
GuideComplete Guide to Mobile Responsive Design
Build layouts that work on every screen. Mobile-first breakpoints, Grid, Flexbox, container queries, fluid type and responsive images.
RecipeCSS Container Queries for Responsive Components
How to use CSS container queries so components adapt to the space they actually get inside a sidebar, grid cell, or card — not just the viewport width.
GuideComplete Guide to React Performance Optimization
Optimize React apps for speed. Covers memoization, virtualization, code splitting, bundle analysis, React Profiler, concurrent features, and Core Web Vitals.