- Published on
Web Accessibility (a11y) Complete Guide 2025: WCAG 2.2, ARIA, Keyboard Navigation
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 1. Why Accessibility Matters
- 2. WCAG 2.2: The 4 Principles
- 3. Semantic HTML: The Foundation of Accessibility
- 4. ARIA: Adding Accessible Semantics
- 5. Keyboard Accessibility
- 6. Color and Contrast
- 7. Images and Media
- 8. Form Accessibility
- 9. React/Next.js Accessibility Patterns
- 10. Testing and Automation
- 11. Legal Requirements and Regulations
- 12. Quiz
- References
1. Why Accessibility Matters
1 Billion Users
Approximately 15% of the world's population -- roughly 1 billion people -- live with some form of disability. Web accessibility is not just "the right thing to do"; it is a business imperative.
| Disability Type | Global Population | Impact on Web |
|---|---|---|
| Visual | 220 million | Screen readers, magnifiers |
| Hearing | 466 million | Captions, sign language |
| Motor | Hundreds of millions | Keyboard, voice input |
| Cognitive | Varies | Simple UI, clear language |
Legal Requirements
- US ADA: Websites are considered "places of public accommodation." Lawsuits surging
- EU EAA (European Accessibility Act): Enforcement began June 2025. Digital services mandatory
- Korea Disability Discrimination Act: Web accessibility mandatory for public and expanding to private sector
- WCAG 2.2: The international standard. Most laws require AA level compliance
Business Value
Accessibility is not a cost -- it is an investment.
- SEO improvement: Semantic HTML and alt text are loved by search engines
- User base expansion: Reach 15% of the global population
- Legal risk reduction: Proactive investment is far cheaper than lawsuits
- Better UX for everyone: Keyboard shortcuts, clear labels benefit all users
2. WCAG 2.2: The 4 Principles
POUR Principles
WCAG is built on 4 core principles: POUR (Perceivable, Operable, Understandable, Robust).
| Principle | Meaning | Examples |
|---|---|---|
| Perceivable | Information must be presentable to users | Alt text, captions, color contrast |
| Operable | UI must be operable | Keyboard access, enough time, seizure prevention |
| Understandable | Content and UI must be understandable | Clear language, consistent navigation |
| Robust | Must work with diverse technologies | Valid HTML, ARIA compatibility |
Conformance Levels
- Level A: Minimum requirements (essential)
- Level AA: What most laws require (recommended target)
- Level AAA: Highest level (difficult to apply site-wide)
New Success Criteria in WCAG 2.2
WCAG 2.2 was published in October 2023 with the following new criteria:
| Success Criterion | Level | Description |
|---|---|---|
| 2.4.11 Focus Not Obscured (Minimum) | AA | Focused element must not be fully hidden by other content |
| 2.4.12 Focus Not Obscured (Enhanced) | AAA | Focused element must not be even partially hidden |
| 2.4.13 Focus Appearance | AAA | Size and contrast requirements for focus indicators |
| 2.5.7 Dragging Movements | AA | Alternatives must be provided for drag-based functionality |
| 2.5.8 Target Size (Minimum) | AA | Touch targets must be at least 24x24 CSS pixels |
| 3.2.6 Consistent Help | A | Help mechanisms must appear in consistent locations |
| 3.3.7 Redundant Entry | A | Previously entered information must not be re-requested |
| 3.3.8 Accessible Authentication | AA | Authentication without cognitive function tests |
| 3.3.9 Accessible Authentication (Enhanced) | AAA | Stricter authentication accessibility |
3. Semantic HTML: The Foundation of Accessibility
Using the Right Elements
Semantic HTML solves 80% of accessibility. Screen readers understand the meaning of HTML elements.
<!-- Bad: Making everything with divs -->
<div class="button" onclick="submit()">Submit</div>
<div class="header">Site Title</div>
<div class="nav">
<div class="link" onclick="goto('/')">Home</div>
</div>
<!-- Good: Using semantic elements -->
<button type="submit">Submit</button>
<header><h1>Site Title</h1></header>
<nav>
<a href="/">Home</a>
</nav>
A <button> automatically receives keyboard focus, is activated with Enter/Space, and is recognized as "button" by screen readers. A <div> requires manually implementing all of this.
Landmarks
<body>
<header>
<nav aria-label="Main navigation">...</nav>
</header>
<main>
<article>
<h1>Article Title</h1>
<section aria-labelledby="section-1">
<h2 id="section-1">Section 1</h2>
...
</section>
</article>
<aside aria-label="Related links">...</aside>
</main>
<footer>...</footer>
</body>
Screen reader users can quickly navigate between landmarks. In VoiceOver, using the Rotor lets them jump directly to header, nav, main, and footer.
Heading Hierarchy
<!-- Bad: Skipping heading levels -->
<h1>Page Title</h1>
<h3>Subsection</h3> <!-- Skipped h2! -->
<h5>Detail</h5> <!-- Skipped h4! -->
<!-- Good: Sequential heading structure -->
<h1>Page Title</h1>
<h2>Section A</h2>
<h3>Subsection A-1</h3>
<h3>Subsection A-2</h3>
<h2>Section B</h2>
<h3>Subsection B-1</h3>
67% of screen reader users navigate pages by headings. Skipping heading levels makes document structure difficult to understand.
4. ARIA: Adding Accessible Semantics
The First Rule of ARIA
The first rule of ARIA: Don't use ARIA. If a native HTML element suffices, ARIA is unnecessary.
<!-- ARIA unnecessary: Native elements are sufficient -->
<button>Delete</button> <!-- role="button" not needed -->
<input type="checkbox" /> <!-- role="checkbox" not needed -->
<nav> <!-- role="navigation" not needed -->
<!-- ARIA needed: No native element exists -->
<div role="tablist">
<button role="tab" aria-selected="true">Tab 1</button>
<button role="tab" aria-selected="false">Tab 2</button>
</div>
<div role="tabpanel">Tab 1 content</div>
Essential ARIA Attributes
<!-- aria-label: Provide a label for elements without visible text -->
<button aria-label="Close menu">
<svg><!-- X icon --></svg>
</button>
<!-- aria-labelledby: Reference another element's text as a label -->
<h2 id="cart-heading">Shopping Cart</h2>
<ul aria-labelledby="cart-heading">
<li>Product 1</li>
<li>Product 2</li>
</ul>
<!-- aria-describedby: Link additional description -->
<input
type="password"
aria-describedby="pw-hint"
/>
<p id="pw-hint">At least 8 characters, including special characters</p>
<!-- aria-live: Announce dynamic content changes -->
<div aria-live="polite">
3 items in your cart.
</div>
<!-- aria-expanded: Expansion/collapse state -->
<button aria-expanded="false" aria-controls="menu">
Menu
</button>
<ul id="menu" hidden>...</ul>
<!-- aria-hidden: Hide from screen readers -->
<span aria-hidden="true">🔥</span>
<span class="sr-only">Popular</span>
Live Regions
<!-- aria-live="polite": Announce after current reading finishes -->
<div aria-live="polite" aria-atomic="true">
Search results: 42 items
</div>
<!-- aria-live="assertive": Announce immediately (for errors) -->
<div role="alert" aria-live="assertive">
Your session has expired. Please log in again.
</div>
<!-- role="status": Status messages (similar to polite) -->
<div role="status">
File upload complete
</div>
<!-- role="log": Chat messages etc. -->
<div role="log" aria-live="polite">
<!-- New messages appended -->
</div>
5. Keyboard Accessibility
Focus Management Basics
Every interactive element must be accessible via keyboard.
| Key | Action |
|---|---|
| Tab | Move to next focusable element |
| Shift + Tab | Move to previous focusable element |
| Enter | Activate links, click buttons |
| Space | Click buttons, toggle checkboxes |
| Escape | Close modals/popups |
| Arrow keys | Navigate within menus, tabs, radio groups |
Skip Links
<!-- Place at the very top of the page -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<nav>
<!-- Long navigation menu -->
</nav>
<main id="main-content" tabindex="-1">
<!-- Main content -->
</main>
.skip-link {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
.skip-link:focus {
position: fixed;
top: 10px;
left: 10px;
width: auto;
height: auto;
padding: 12px 24px;
background: #000;
color: #fff;
z-index: 9999;
font-size: 1rem;
}
Focus Trap
When a modal is open, focus must cycle only within the modal.
function useFocusTrap(containerRef: React.RefObject<HTMLElement>) {
useEffect(() => {
const container = containerRef.current
if (!container) return
const focusableSelector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ')
const focusableElements = container.querySelectorAll(focusableSelector)
const firstElement = focusableElements[0] as HTMLElement
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault()
lastElement.focus()
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault()
firstElement.focus()
}
}
}
container.addEventListener('keydown', handleKeyDown)
firstElement?.focus()
return () => container.removeEventListener('keydown', handleKeyDown)
}, [containerRef])
}
Tab Order Management
<!-- tabindex values -->
<!-- tabindex="0": Include in natural order -->
<div role="button" tabindex="0">Custom button</div>
<!-- tabindex="-1": Only focusable programmatically -->
<div id="error-message" tabindex="-1">An error occurred!</div>
<!-- Never use positive tabindex! It breaks the order -->
<!-- Bad: tabindex="1", tabindex="2", tabindex="3" -->
6. Color and Contrast
Contrast Ratio Requirements
| Text Type | AA Level | AAA Level |
|---|---|---|
| Normal text (under 14px) | 4.5:1 | 7:1 |
| Large text (18px+ or 14px bold) | 3:1 | 4.5:1 |
| UI components, graphics | 3:1 | - |
Ensuring Contrast with CSS
/* Good contrast: #333 on #fff = 12.63:1 */
body {
color: #333333;
background-color: #ffffff;
}
/* Links: 3:1 contrast with surrounding text + underline or other visual cue */
a {
color: #0066cc;
text-decoration: underline;
}
/* Focus indicator: 3:1 contrast required */
:focus-visible {
outline: 3px solid #1a73e8;
outline-offset: 2px;
}
/* Maintain contrast in dark mode too */
@media (prefers-color-scheme: dark) {
body {
color: #e0e0e0;
background-color: #121212;
}
a {
color: #8ab4f8;
}
}
Color Blindness Considerations
/* Never convey information through color alone */
/* Bad: Error indicated only by color */
.error-field {
border-color: red;
}
/* Good: Color + icon + text */
.error-field {
border-color: #d32f2f;
border-width: 2px;
}
.error-field::before {
content: "⚠ ";
}
.error-message {
color: #d32f2f;
font-weight: bold;
}
Contrast Checking Tools
- Chrome DevTools: Shows contrast ratio when inspecting elements
- axe DevTools: Full-page contrast audit
- Colour Contrast Analyser (CCA): Standalone tool
- Stark: Figma/Sketch plugin
7. Images and Media
Alt Text Guide
<!-- Informative image: Describe the content -->
<img src="chart.png" alt="2025 revenue trend: Q1 1M, Q2 1.5M, Q3 2M" />
<!-- Decorative image: Empty alt -->
<img src="decorative-line.png" alt="" />
<!-- Functional image (link/button): Describe the action -->
<a href="/home">
<img src="logo.png" alt="Go to homepage" />
</a>
<!-- Complex image: Provide long description -->
<figure>
<img src="infographic.png" alt="Accessibility statistics infographic" aria-describedby="info-desc" />
<figcaption id="info-desc">
1 billion people worldwide have disabilities,
and 97% of websites contain accessibility errors.
The most common error is low color contrast (83%).
</figcaption>
</figure>
<!-- SVG accessibility -->
<svg role="img" aria-labelledby="svg-title">
<title id="svg-title">Download icon</title>
<path d="..." />
</svg>
Video Accessibility
<video controls>
<source src="tutorial.mp4" type="video/mp4" />
<!-- Captions -->
<track kind="captions" src="captions-en.vtt" srclang="en" label="English" default />
<track kind="captions" src="captions-ko.vtt" srclang="ko" label="Korean" />
<!-- Audio descriptions -->
<track kind="descriptions" src="descriptions-en.vtt" srclang="en" label="Audio descriptions" />
</video>
Audio Content
All audio content requires a text alternative (transcript).
8. Form Accessibility
Connecting Labels and Inputs
<!-- Method 1: for/id association (recommended) -->
<label for="email">Email address</label>
<input type="email" id="email" name="email" autocomplete="email" />
<!-- Method 2: Wrapping with label -->
<label>
Email address
<input type="email" name="email" autocomplete="email" />
</label>
<!-- Method 3: aria-labelledby -->
<span id="email-label">Email address</span>
<input type="email" aria-labelledby="email-label" autocomplete="email" />
Error Messages and Validation
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
aria-describedby="pw-requirements pw-error"
aria-invalid="true"
autocomplete="new-password"
/>
<p id="pw-requirements" class="hint">
At least 8 characters, including uppercase, lowercase, numbers, and special characters
</p>
<p id="pw-error" class="error" role="alert">
Password does not meet the requirements.
</p>
</div>
Required Fields
<!-- aria-required + visual indicator -->
<label for="name">
Name <span aria-hidden="true" class="required">*</span>
</label>
<input
type="text"
id="name"
required
aria-required="true"
autocomplete="name"
/>
<p class="form-note">* indicates required fields</p>
Autocomplete
<!-- WCAG 1.3.5: Use autocomplete attributes -->
<input type="text" autocomplete="given-name" /> <!-- First name -->
<input type="text" autocomplete="family-name" /> <!-- Last name -->
<input type="email" autocomplete="email" /> <!-- Email -->
<input type="tel" autocomplete="tel" /> <!-- Phone -->
<input type="text" autocomplete="street-address" /> <!-- Address -->
9. React/Next.js Accessibility Patterns
Focus Management in SPAs
In SPAs (Single Page Applications), focus does not automatically move during page transitions.
// Move focus on route change
function useRouteAnnounce() {
const pathname = usePathname()
useEffect(() => {
// Move focus to main content
const main = document.querySelector('main')
if (main) {
main.setAttribute('tabindex', '-1')
main.focus()
}
}, [pathname])
return (
<div
role="status"
aria-live="polite"
className="sr-only"
>
Page has loaded
</div>
)
}
Route Change Announcements
// Next.js App Router: Route change announcements
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
function RouteAnnouncer() {
const pathname = usePathname()
const [announcement, setAnnouncement] = useState('')
useEffect(() => {
const pageTitle = document.title
setAnnouncement(`Navigated to ${pageTitle}`)
}, [pathname])
return (
<div
role="status"
aria-live="assertive"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
)
}
Accessible Modal (Dialog)
'use client'
import { useEffect, useRef } from 'react'
interface DialogProps {
isOpen: boolean
onClose: () => void
title: string
children: React.ReactNode
}
function AccessibleDialog({ isOpen, onClose, title, children }: DialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement
dialog.showModal()
} else {
dialog.close()
previousFocusRef.current?.focus()
}
}, [isOpen])
return (
<dialog
ref={dialogRef}
aria-labelledby="dialog-title"
onClose={onClose}
>
<h2 id="dialog-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</dialog>
)
}
Leveraging Radix UI / Headless UI
import * as Dialog from '@radix-ui/react-dialog'
// Radix UI handles accessibility automatically
function MyDialog() {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button>Edit Profile</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="overlay" />
<Dialog.Content className="content">
<Dialog.Title>Edit Profile</Dialog.Title>
<Dialog.Description>
Make changes to your profile information.
</Dialog.Description>
{/* Form fields */}
<Dialog.Close asChild>
<button aria-label="Close">X</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
Screen Reader Only Text
/* sr-only utility class */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
// Usage examples
<button>
<TrashIcon />
<span className="sr-only">Delete item</span>
</button>
<a href="/cart">
<CartIcon />
<span className="sr-only">Shopping cart (3 items)</span>
</a>
10. Testing and Automation
Automated Testing with axe-core
// jest + axe-core
import { render } from '@testing-library/react'
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
describe('Button', () => {
it('has no accessibility violations', async () => {
const { container } = render(<Button>Click</Button>)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
})
Playwright + axe Integration Tests
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test('homepage accessibility', async ({ page }) => {
await page.goto('/')
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze()
expect(accessibilityScanResults.violations).toEqual([])
})
// Keyboard navigation test
test('navigate menu with keyboard', async ({ page }) => {
await page.goto('/')
// Tab to Skip Link
await page.keyboard.press('Tab')
const skipLink = page.getByText('Skip to main content')
await expect(skipLink).toBeFocused()
// Enter to activate Skip Link
await page.keyboard.press('Enter')
const main = page.locator('main')
await expect(main).toBeFocused()
})
Lighthouse Accessibility Score
# Run Lighthouse in CI
npx lighthouse http://localhost:3000 \
--only-categories=accessibility \
--output=json \
--output-path=./lighthouse-report.json
// Check accessibility score in CI pipeline
const report = JSON.parse(fs.readFileSync('./lighthouse-report.json', 'utf-8'))
const accessibilityScore = report.categories.accessibility.score * 100
if (accessibilityScore < 90) {
console.error(`Accessibility score ${accessibilityScore} - must be 90 or above`)
process.exit(1)
}
Manual Screen Reader Testing Checklist
| Task | VoiceOver (Mac) | NVDA (Windows) | TalkBack (Android) |
|---|---|---|---|
| Read page title | Cmd + F5 | Insert + T | Automatic |
| Navigate landmarks | Rotor (VO + U) | D/Shift+D | Swipe |
| Navigate headings | VO + Cmd + H | H/Shift+H | Swipe |
| Form fields | VO + Tab | Tab | Touch explore |
| Link list | Rotor | Insert + F7 | Menu |
11. Legal Requirements and Regulations
US: ADA and Section 508
- ADA Title III: Websites are "places of public accommodation." WCAG 2.1 AA required
- Section 508: Federal government websites mandatory. WCAG 2.0 AA standard
- Lawsuit trends: Over 4,600 web accessibility lawsuits in 2023
EU: European Accessibility Act (EAA)
- Enforcement date: June 28, 2025
- Targets businesses providing digital services
- Requires WCAG 2.1 AA or higher
- Fines for violations
Korea: Disability Discrimination Act
- Anti-Discrimination Against and Remedies for Persons with Disabilities Act (2008)
- Web Accessibility Certification Mark by the Korea Web Accessibility Certification Center
- Mandatory for public institutions, expanding to private sector
- KWCAG 2.2: Based on WCAG 2.2
Accessibility Statement
<!-- Recommended to include on your website -->
<h1>Accessibility Statement</h1>
<p>
We are committed to ensuring our website is accessible
to everyone by adhering to WCAG 2.2 AA standards.
</p>
<p>
If you encounter any accessibility issues, please contact us at
<a href="mailto:a11y@example.com">a11y@example.com</a>.
</p>
12. Quiz
Q1. Explain WCAG's 4 principles (POUR) and provide an example for each.
Perceivable: All information must be presentable to users. Examples include providing alt text for images and captions for videos. Operable: All functionality must be operable. Examples include full keyboard access and providing enough time. Understandable: Content and UI must be understandable. Examples include clear error messages and consistent navigation. Robust: Must work with diverse technologies. Examples include valid HTML and assistive technology compatibility.
Q2. What is the first rule of ARIA, and why is it important?
The first rule of ARIA is "if a native HTML element can be used, do not use ARIA." For example, adding role="button" to a button element is unnecessary. Native HTML elements already have built-in accessible semantics, keyboard behavior, and focus management. Incorrectly used ARIA can actually harm accessibility rather than improve it.
Q3. When do the 4.5:1 and 3:1 contrast ratios apply?
4.5:1 is the AA-level requirement for normal-sized text (under 18px). 3:1 is the AA-level requirement for large text (18px or larger, or 14px bold) and UI components (button borders, input fields, etc.). AAA level requires 7:1 for normal text and 4.5:1 for large text.
Q4. How do you ensure accessibility during route changes in a SPA?
Client-side routing in SPAs does not provide automatic screen reader notifications like traditional page loads. Solutions include moving focus to main content on route change, announcing the new page title via aria-live region, updating the document title, and providing skip links. Next.js App Router provides built-in route announcement functionality.
Q5. How do you integrate axe-core into a CI/CD pipeline, and what are its limitations?
axe-core can be integrated into CI via jest-axe (unit tests) or @axe-core/playwright (E2E tests). You can filter by WCAG tags to test specific criteria and fail the build on violations. The limitation is that automated tools can only detect approximately 30-40% of accessibility issues. Keyboard usability, screen reader compatibility, and cognitive accessibility require manual testing.
References
- WCAG 2.2 - W3C Recommendation
- WAI-ARIA 1.2 Specification
- MDN Web Accessibility Guide
- A11y Project Checklist
- Deque axe-core
- WebAIM Million Report
- Radix UI Accessibility
- React Accessibility Docs
- Next.js Accessibility
- Inclusive Components by Heydon Pickering
- EU European Accessibility Act
- Korea Web Accessibility Certification Center
- Chrome DevTools Accessibility
- Stark Accessibility Tools