Making the web accessible to everyone, including users with disabilities
Web Accessibility (A11y) means designing and developing websites, tools, and technologies so that people with disabilities can use them. It ensures equal access and equal opportunity to people with diverse abilities.
The web should be accessible to everyone regardless of ability. It's about inclusivity and equal access to information and services.
Semantic HTML uses meaningful elements that describe their content. This is crucial for screen readers and assistive technologies.
<!-- Proper semantic structure -->
<header>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h1>Article Title</h1>
<p>Article content...</p>
</article>
<aside>
<h2>Related Links</h2>
<ul>
<li><a href="#">Link 1</a></li>
</ul>
</aside>
</main>
<footer>
<p>© 2026 Company Name</p>
</footer><!-- Don't use divs for everything -->
<div class="header">
<div class="nav">
<div class="menu-item">Home</div>
<div class="menu-item">About</div>
</div>
</div>
<div class="main">
<div class="content">
<span class="title">Article Title</span>
<div>Article content...</div>
</div>
</div><header> - Site/page header<nav> - Navigation links<main> - Main content (only one per page)<article> - Self-contained content<section> - Thematic grouping of content<aside> - Sidebar or tangential content<footer> - Site/page footer<h1>-<h6> - Headings (proper hierarchy)<button> - Interactive buttons<a> - Links for navigationARIA provides a way to make web content and applications more accessible when semantic HTML alone isn't enough. It's especially useful for dynamic content and complex UI components.
"Don't use ARIA if you can use semantic HTML instead!"
Native HTML elements have built-in accessibility features. Only use ARIA when HTML can't provide the semantics you need.
Define what an element is or does
<!-- Landmark roles -->
<div role="banner">Site header</div>
<div role="navigation">Nav menu</div>
<div role="main">Main content</div>
<div role="complementary">Sidebar</div>
<div role="contentinfo">Footer</div>
<!-- Widget roles -->
<div role="button">Click me</div>
<div role="tab">Tab 1</div>
<div role="tabpanel">Tab content</div>
<div role="dialog">Modal content</div>
<div role="alert">Important message!</div>
<div role="progressbar" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"></div>
<!-- ⚠️ Better: Use semantic HTML when possible -->
<button>Click me</button> <!-- Instead of div role="button" -->
<nav>Nav menu</nav> <!-- Instead of div role="navigation" -->Describe the current state or properties of elements
<!-- aria-label: Provides accessible name -->
<button aria-label="Close dialog">
<span aria-hidden="true">×</span>
</button>
<!-- aria-labelledby: References another element for label -->
<h2 id="modal-title">Confirmation</h2>
<div role="dialog" aria-labelledby="modal-title">
Are you sure?
</div>
<!-- aria-describedby: Provides additional description -->
<input
type="password"
aria-describedby="password-hint"
id="password"
/>
<span id="password-hint">
Password must be at least 8 characters
</span>
<!-- aria-expanded: Indicates expansion state -->
<button aria-expanded="false" aria-controls="menu">
Menu
</button>
<ul id="menu" hidden>
<li>Item 1</li>
</ul>
<!-- aria-hidden: Hides from screen readers -->
<span aria-hidden="true">🎉</span>
<span class="sr-only">Celebration icon</span>
<!-- aria-live: Announces dynamic content -->
<div aria-live="polite" role="status">
Item added to cart
</div>
<!-- aria-current: Indicates current item -->
<nav>
<a href="/" aria-current="page">Home</a>
<a href="/about">About</a>
</nav>
<!-- aria-disabled: Indicates disabled state -->
<button aria-disabled="true">Submit</button>
<!-- aria-required: Indicates required field -->
<input type="text" aria-required="true" />
<!-- aria-invalid: Indicates validation error -->
<input
type="email"
aria-invalid="true"
aria-describedby="email-error"
/>
<span id="email-error">Please enter a valid email</span>Announce dynamic content changes to screen readers
<!-- aria-live values -->
<div aria-live="off">Not announced</div>
<div aria-live="polite">Announced when user is idle</div>
<div aria-live="assertive">Announced immediately</div>
<!-- Status messages -->
<div role="status" aria-live="polite">
Form saved successfully
</div>
<!-- Alerts -->
<div role="alert" aria-live="assertive">
Error: Connection lost
</div>
<!-- Loading states -->
<div role="status" aria-live="polite" aria-busy="true">
Loading content...
</div>import { useState, useRef, useEffect } from 'react';
export default function AccessibleModal() {
const [isOpen, setIsOpen] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const openButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) {
// Focus modal when opened
modalRef.current?.focus();
} else {
// Return focus to button when closed
openButtonRef.current?.focus();
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
// Trap focus inside modal
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
return (
<>
<button
ref={openButtonRef}
onClick={() => setIsOpen(true)}
aria-haspopup="dialog"
>
Open Modal
</button>
{isOpen && (
<>
{/* Backdrop */}
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
}}
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
{/* Modal */}
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
tabIndex={-1}
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'white',
padding: '20px',
borderRadius: '8px',
}}
>
<h2 id="modal-title">Confirm Action</h2>
<p id="modal-description">
Are you sure you want to proceed?
</p>
<div>
<button onClick={() => setIsOpen(false)}>
Cancel
</button>
<button onClick={() => {
// Handle confirm
setIsOpen(false);
}}>
Confirm
</button>
</div>
<button
onClick={() => setIsOpen(false)}
aria-label="Close dialog"
style={{
position: 'absolute',
top: '10px',
right: '10px',
}}
>
<span aria-hidden="true">×</span>
</button>
</div>
</>
)}
</>
);
}Screen readers are software programs that read the content of the screen aloud to users who are blind or have low vision. Understanding how they work is crucial for building accessible websites.
/* CSS for screen reader only text */
.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;
}
/* For focusable elements */
.sr-only-focusable:focus {
position: static;
width: auto;
height: auto;
padding: inherit;
margin: inherit;
overflow: visible;
clip: auto;
white-space: normal;
}<!-- Usage examples -->
<button>
<svg aria-hidden="true">...</svg>
<span class="sr-only">Delete item</span>
</button>
<!-- Skip to main content link -->
<a href="#main-content" class="sr-only-focusable">
Skip to main content
</a>
<main id="main-content">
<!-- Content -->
</main>
<!-- Icon with accessible text -->
<span aria-hidden="true">🔍</span>
<span class="sr-only">Search</span>// VoiceOver on macOS
// Cmd + F5 to toggle
// Common commands:
// - Control + Option + Right Arrow: Read next item
// - Control + Option + Command + H: Next heading
// - Control + Option + U: Rotor (lists headings, links, etc.)
// NVDA on Windows (Free!)
// Install from nvaccess.org
// Common commands:
// - NVDA + Down Arrow: Read next item
// - H: Next heading
// - Insert + F7: Elements listAll images must have alternative text that conveys the same information as the image for users who can't see it.
<!-- ✅ Informative images - Describe the content -->
<img
src="chart.png"
alt="Bar chart showing 50% increase in sales from 2024 to 2025"
/>
<!-- ✅ Functional images - Describe the function -->
<a href="/home">
<img src="logo.png" alt="Company Name - Home" />
</a>
<button>
<img src="search-icon.png" alt="Search" />
</button>
<!-- ✅ Decorative images - Empty alt -->
<img src="decorative-border.png" alt="" />
<!-- Or use CSS background images -->
<!-- ✅ Complex images - Provide longer description -->
<figure>
<img
src="complex-diagram.png"
alt="Architecture diagram"
aria-describedby="diagram-description"
/>
<figcaption id="diagram-description">
Detailed description of the system architecture...
</figcaption>
</figure>
<!-- ❌ Don't do this -->
<img src="cat.jpg" alt="image" />
<img src="cat.jpg" alt="cat.jpg" />
<img src="cat.jpg" alt="Click here" />
<img src="cat.jpg" /> <!-- Missing alt! -->
<!-- ✅ Better -->
<img src="cat.jpg" alt="Orange tabby cat sleeping on a blue couch" />import Image from 'next/image';
// ✅ Always provide alt text
<Image
src="/product.jpg"
alt="Modern desk lamp with adjustable arm"
width={300}
height={400}
/>
// ✅ Decorative images
<Image
src="/decorative.jpg"
alt=""
aria-hidden="true"
width={300}
height={400}
/>
// ✅ Background images for decoration
<div
style={{ backgroundImage: 'url(/decorative.jpg)' }}
role="presentation"
>
Content
</div>Forms are critical for user interaction and must be fully accessible to all users.
<!-- ✅ Always label inputs -->
<label for="email">Email Address</label>
<input type="email" id="email" name="email" required />
<!-- ✅ Complex labels with descriptions -->
<label for="password">
Password
<span aria-describedby="password-help">(required)</span>
</label>
<input
type="password"
id="password"
name="password"
aria-describedby="password-help"
required
/>
<small id="password-help">
Must be at least 8 characters with numbers and letters
</small>
<!-- ✅ Placeholder is NOT a label replacement -->
<label for="search">Search</label>
<input
type="search"
id="search"
placeholder="Enter keywords..."
/>
<!-- ❌ Don't do this -->
<input type="text" placeholder="Name" /> <!-- No label! -->
<!-- ✅ Group related fields -->
<fieldset>
<legend>Shipping Address</legend>
<label for="street">Street</label>
<input type="text" id="street" />
<label for="city">City</label>
<input type="text" id="city" />
</fieldset>
<!-- ✅ Radio buttons and checkboxes -->
<fieldset>
<legend>Choose your subscription:</legend>
<input type="radio" id="free" name="plan" value="free" />
<label for="free">Free Plan</label>
<input type="radio" id="pro" name="plan" value="pro" />
<label for="pro">Pro Plan</label>
</fieldset><!-- Error summary at top -->
<div role="alert" aria-live="assertive">
<h2>There are 2 errors in this form:</h2>
<ul>
<li><a href="#email">Email is required</a></li>
<li><a href="#password">Password is too short</a></li>
</ul>
</div>
<!-- Individual field errors -->
<label for="email">Email Address</label>
<input
type="email"
id="email"
aria-invalid="true"
aria-describedby="email-error"
/>
<span id="email-error" role="alert">
Please enter a valid email address
</span>
<style>
/* Visual error styling */
input[aria-invalid="true"] {
border-color: #f38ba8;
}
[role="alert"] {
color: #f38ba8;
font-weight: 600;
}
</style>import { useState } from 'react';
export default function AccessibleForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const validate = (name: string, value: string) => {
if (name === 'email' && !value.includes('@')) {
return 'Please enter a valid email';
}
if (name === 'password' && value.length < 8) {
return 'Password must be at least 8 characters';
}
return '';
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setTouched({ ...touched, [name]: true });
const error = validate(name, value);
setErrors({ ...errors, [name]: error });
};
return (
<form>
{/* Error summary */}
{Object.keys(errors).some(key => errors[key]) && (
<div role="alert" aria-live="polite">
<h2>Please fix the following errors:</h2>
<ul>
{Object.entries(errors).map(([field, error]) =>
error ? (
<li key={field}>
<a href={`#${field}`}>{error}</a>
</li>
) : null
)}
</ul>
</div>
)}
{/* Email field */}
<div>
<label htmlFor="email">
Email Address
<span aria-label="required">*</span>
</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-invalid={touched.email && !!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
onBlur={handleBlur}
/>
{touched.email && errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
{/* Password field */}
<div>
<label htmlFor="password">
Password
<span aria-label="required">*</span>
</label>
<input
type="password"
id="password"
name="password"
required
aria-required="true"
aria-invalid={touched.password && !!errors.password}
aria-describedby="password-help password-error"
onBlur={handleBlur}
/>
<small id="password-help">
Must be at least 8 characters
</small>
{touched.password && errors.password && (
<span id="password-error" role="alert">
{errors.password}
</span>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}Proper color contrast ensures text is readable for users with low vision or color blindness.
/* ✅ Good contrast (4.5:1+) */
.text {
color: #000000; /* Black */
background: #FFFFFF; /* White */
/* Contrast ratio: 21:1 */
}
.button {
color: #FFFFFF; /* White */
background: #0066CC; /* Blue */
/* Contrast ratio: 4.55:1 */
}
/* ❌ Poor contrast (below 4.5:1) */
.bad-text {
color: #777777; /* Light gray */
background: #FFFFFF; /* White */
/* Contrast ratio: 4.4:1 - Not enough! */
}
/* Tools to check contrast: */
// - WebAIM Contrast Checker: webaim.org/resources/contrastchecker/
// - Chrome DevTools: Inspect element > Accessibility panel
// - Browser extension: "WAVE" or "axe DevTools"<!-- ❌ Color only -->
<span style="color: red;">Required field</span>
<!-- ✅ Color + icon + text -->
<span style="color: #f38ba8;">
<span aria-hidden="true">*</span>
<span class="sr-only">Required: </span>
Required field
</span>
<!-- ❌ Color-coded status -->
<div style="background: green;">Success</div>
<div style="background: red;">Error</div>
<!-- ✅ Color + icon + text -->
<div style="background: #a6e3a1;">
<span aria-hidden="true">✓</span>
Success
</div>
<div style="background: #f38ba8;">
<span aria-hidden="true">✗</span>
Error
</div>/* ✅ Visible focus with sufficient contrast */
:focus-visible {
outline: 2px solid #0066CC;
outline-offset: 2px;
}
/* ✅ High contrast mode compatible */
@media (prefers-contrast: high) {
:focus-visible {
outline-width: 3px;
}
}Make interactive elements large enough to tap easily.
/* ✅ WCAG 2.1: Minimum 44x44 CSS pixels */
button,
a,
input[type="checkbox"],
input[type="radio"] {
min-width: 44px;
min-height: 44px;
/* Or use padding to reach 44px */
padding: 12px 16px;
}
/* ✅ Spacing between touch targets */
.button-group button {
margin: 8px;
}
/* ❌ Too small */
.icon-button {
width: 20px;
height: 20px; /* Not accessible! */
}<!-- ✅ Allow zoom -->
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<!-- ❌ Don't disable zoom -->
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
/* ✅ Use relative units for text */
body {
font-size: 16px; /* Base */
}
h1 {
font-size: 2rem; /* Scales with user preferences */
}
/* ❌ Avoid fixed pixel sizes for text */
p {
font-size: 12px; /* Doesn't scale! */
}/* ✅ Support both portrait and landscape */
@media (orientation: landscape) {
.container {
flex-direction: row;
}
}
@media (orientation: portrait) {
.container {
flex-direction: column;
}
}
/* Don't force orientation */
/* Let users choose! */// Install jest-axe
npm install --save-dev jest-axe @axe-core/react
// Test with jest-axe
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('should have no accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Cypress accessibility testing
npm install --save-dev cypress-axe
// cypress/e2e/accessibility.cy.ts
describe('Accessibility', () => {
it('has no detectable a11y violations', () => {
cy.visit('/');
cy.injectAxe();
cy.checkA11y();
});
});<header>, <nav>, <main>, etc.)<main> element per pagealt=""required or aria-required// Use native button when possible
<button onClick={handleClick}>
Click Me
</button>
// Icon button with label
<button aria-label="Close">
<span aria-hidden="true">×</span>
</button>
// Icon + text
<button>
<svg aria-hidden="true">...</svg>
<span>Delete</span>
</button>
// If you must use div (avoid!)
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
Click Me
</div>export default function AccessibleTabs() {
const [activeTab, setActiveTab] = useState(0);
const tabs = ['Tab 1', 'Tab 2', 'Tab 3'];
return (
<div>
<div role="tablist" aria-label="Example tabs">
{tabs.map((tab, index) => (
<button
key={index}
role="tab"
aria-selected={activeTab === index}
aria-controls={`panel-${index}`}
id={`tab-${index}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') {
setActiveTab((activeTab + 1) % tabs.length);
} else if (e.key === 'ArrowLeft') {
setActiveTab((activeTab - 1 + tabs.length) % tabs.length);
}
}}
>
{tab}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={index}
role="tabpanel"
id={`panel-${index}`}
aria-labelledby={`tab-${index}`}
hidden={activeTab !== index}
tabIndex={0}
>
Content for {tab}
</div>
))}
</div>
);
}export default function AccessibleDropdown() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button
aria-haspopup="true"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
>
Menu
</button>
{isOpen && (
<ul role="menu">
<li role="none">
<button role="menuitem" onClick={() => {}}>
Option 1
</button>
</li>
<li role="none">
<button role="menuitem" onClick={() => {}}>
Option 2
</button>
</li>
</ul>
)}
</div>
);
}export default function LoadingButton() {
const [isLoading, setIsLoading] = useState(false);
return (
<button
disabled={isLoading}
aria-busy={isLoading}
onClick={async () => {
setIsLoading(true);
await fetch('/api/endpoint');
setIsLoading(false);
}}
>
{isLoading ? (
<>
<span aria-hidden="true">⏳</span>
<span className="sr-only">Loading...</span>
</>
) : (
'Submit'
)}
</button>
);
}