Mastering Web Accessibility: Building for Everyone
A comprehensive guide to building accessible web applications that work beautifully for all users, regardless of ability.

Why I care about accessibility#
Here's a stat that changed how I build: 15% of the world's population experiences some form of disability. That's over 1 billion people. And everyone experiences temporary or situational disabilities—a broken arm, bright sunlight on a screen, using a mobile device one-handed while carrying groceries.
Accessibility isn't a nice-to-have. It's about building products that work for everyone.
Let me show you how to make accessibility a natural part of your development process.
The POUR principles#
The Web Content Accessibility Guidelines (WCAG) are built on four principles:
- Perceivable - Information must be presentable to users in ways they can perceive
- Operable - User interface components must be operable
- Understandable - Information and UI must be understandable
- Robust - Content must be robust enough to work with current and future technologies
Let's explore each with practical examples.
Perceivable: See, hear, or feel your content#
Vision: Not everyone sees the same way#
Some users are blind, some have low vision, some are colorblind. Your interface needs to work for all of them.
1. Text alternatives for images
// ❌ Bad: No context
<img src="/chart.png" />
// ❌ Also bad: Vague description
<img src="/chart.png" alt="Chart" />
// ✅ Good: Descriptive alternative
<img
src="/chart.png"
alt="Bar chart showing website traffic increased 45% from January to June 2026"
/>
// ✅ Complex images: Use longdesc or aria-describedby
<img
src="/complex-diagram.png"
alt="System architecture diagram"
aria-describedby="diagram-description"
/>
<div id="diagram-description" className="sr-only">
The system consists of three layers: a React frontend
communicating via REST API to a Node.js backend, which
connects to a PostgreSQL database. Redis is used for caching...
</div>Screen reader only class:
/* styles/utilities.css */
.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;
}2. Color contrast
// lib/color-contrast.ts
export function getContrastRatio(color1: string, color2: string): number {
const getLuminance = (rgb: number[]) => {
const [r, g, b] = rgb.map((val) => {
val = val / 255;
return val <= 0.03928
? val / 12.92
: Math.pow((val + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
// Parse colors and calculate luminance...
const l1 = getLuminance(parseColor(color1));
const l2 = getLuminance(parseColor(color2));
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// Usage in component
export function Button({ children }: { children: React.ReactNode }) {
// Minimum 4.5:1 for normal text
// Minimum 3:1 for large text (18pt+)
return (
<button
className="bg-blue-600 text-white" // 4.52:1 ratio ✓
>
{children}
</button>
);
}Don't rely on color alone:
// ❌ Bad: Color as only indicator
<div className="text-red-500">Error occurred</div>
<div className="text-green-500">Success</div>
// ✅ Good: Color + icon + text
<div className="flex items-center gap-2 text-red-500">
<XCircleIcon className="h-5 w-5" aria-hidden="true" />
<span>Error: File upload failed</span>
</div>
<div className="flex items-center gap-2 text-green-500">
<CheckCircleIcon className="h-5 w-5" aria-hidden="true" />
<span>Success: File uploaded</span>
</div>3. Responsive text sizing
// ❌ Bad: Fixed pixel sizes that don't scale
<p style={{ fontSize: '14px' }}>Text</p>
// ✅ Good: Relative units that scale with user preferences
<p className="text-base"> {/* Uses rem units */}
Text
</p>
// Tailwind config with accessible scale
// tailwind.config.ts
export default {
theme: {
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.5rem' }],
lg: ['1.125rem', { lineHeight: '1.75rem' }],
xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
},
},
};4. Audio and video alternatives
export function VideoPlayer({ src }: { src: string }) {
return (
<div>
<video controls>
<source src={src} type="video/mp4" />
{/* Captions for deaf/hard of hearing */}
<track
kind="captions"
src="/captions-en.vtt"
srcLang="en"
label="English"
default
/>
{/* Descriptions for blind users */}
<track
kind="descriptions"
src="/descriptions-en.vtt"
srcLang="en"
label="English descriptions"
/>
</video>
{/* Transcript for those who prefer reading */}
<details className="mt-4">
<summary>View transcript</summary>
<div className="p-4 bg-gray-50">
<h3>Video Transcript</h3>
<p>In this video, we demonstrate...</p>
</div>
</details>
</div>
);
}Operable: Everyone can use your interface#
Keyboard navigation must work perfectly#
Many users can't use a mouse. Your entire interface should be keyboard-accessible.
1. Focus management
// All interactive elements must be focusable
export function CustomButton({
onClick,
children,
}: {
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{children}
</button>
);
}
// If you must use divs, make them keyboard accessible
export function CustomDiv({
onClick,
children,
}: {
onClick: () => void;
children: React.ReactNode;
}) {
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
className="cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{children}
</div>
);
}2. Skip links for screen readers
// components/skip-link.tsx
export function SkipLink() {
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white"
>
Skip to main content
</a>
);
}
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<SkipLink />
<header>...</header>
<main id="main-content">{children}</main>
</body>
</html>
);
}3. Keyboard traps and focus restoration
"use client";
import { useEffect, useRef } from "react";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
export function Modal({
isOpen,
onClose,
children,
}: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Store the element that had focus before opening
previousActiveElement.current = document.activeElement as HTMLElement;
} else {
// Restore focus when closing
previousActiveElement.current?.focus();
}
}, [isOpen]);
return (
<Dialog open={isOpen} onClose={onClose}>
{/* Focus is automatically trapped in dialog */}
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel className="bg-white rounded p-6">
<DialogTitle className="text-lg font-bold">Dialog Title</DialogTitle>
{children}
<div className="mt-4 flex gap-2">
<button onClick={onClose}>Cancel</button>
<button onClick={onClose}>Confirm</button>
</div>
</DialogPanel>
</div>
</Dialog>
);
}4. Accessible dropdown navigation
"use client";
import { useState, useRef, useEffect } from "react";
export function DropdownMenu({
label,
items,
}: {
label: string;
items: Array<{ href: string; label: string }>;
}) {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case "Escape":
setIsOpen(false);
break;
case "ArrowDown":
e.preventDefault();
// Focus first item
const firstItem = menuRef.current?.querySelector("a");
firstItem?.focus();
break;
}
};
return (
<div ref={menuRef} className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
aria-expanded={isOpen}
aria-haspopup="true"
aria-controls="dropdown-menu"
>
{label}
</button>
{isOpen && (
<div
id="dropdown-menu"
role="menu"
className="absolute mt-2 bg-white shadow-lg rounded"
>
{items.map((item, index) => (
<a
key={item.href}
href={item.href}
role="menuitem"
className="block px-4 py-2 hover:bg-gray-100"
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
const next = e.currentTarget
.nextElementSibling as HTMLElement;
next?.focus();
} else if (e.key === "ArrowUp") {
e.preventDefault();
const prev = e.currentTarget
.previousElementSibling as HTMLElement;
prev?.focus();
}
}}
>
{item.label}
</a>
))}
</div>
)}
</div>
);
}5. No time limits (or make them adjustable)
"use client";
import { useState, useEffect } from "react";
export function SessionTimeout({ timeoutMs = 300000 }: { timeoutMs?: number }) {
const [remaining, setRemaining] = useState(timeoutMs);
const [isExtended, setIsExtended] = useState(false);
useEffect(() => {
const interval = setInterval(() => {
setRemaining((prev) => Math.max(0, prev - 1000));
}, 1000);
return () => clearInterval(interval);
}, []);
// Show warning at 1 minute remaining
if (remaining <= 60000 && remaining > 0) {
return (
<div
role="alert"
aria-live="assertive"
className="fixed bottom-4 right-4 bg-yellow-100 border border-yellow-400 p-4 rounded"
>
<p className="font-bold">Session expiring soon</p>
<p>Your session will expire in {Math.ceil(remaining / 1000)} seconds</p>
<button
onClick={() => {
setRemaining(timeoutMs);
setIsExtended(true);
}}
className="mt-2 px-4 py-2 bg-blue-600 text-white rounded"
>
Extend Session
</button>
</div>
);
}
return null;
}Understandable: Clear and predictable#
Forms that guide users#
"use client";
import { useState } from "react";
export function AccessibleForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string;
const newErrors: Record<string, string> = {};
if (!email) {
newErrors.email = "Email is required";
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = "Please enter a valid email address";
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
// Focus first error
const firstErrorField = document.querySelector(
'[aria-invalid="true"]',
) as HTMLElement;
firstErrorField?.focus();
} else {
// Submit form
setErrors({});
}
};
return (
<form onSubmit={handleSubmit} noValidate>
<div className="mb-4">
<label htmlFor="email" className="block mb-2 font-medium">
Email address
<span className="text-red-500" aria-label="required">
*
</span>
</label>
<input
type="email"
id="email"
name="email"
aria-required="true"
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : "email-hint"}
className={`w-full px-3 py-2 border rounded ${
errors.email ? "border-red-500" : "border-gray-300"
}`}
/>
{!errors.email && (
<p id="email-hint" className="mt-1 text-sm text-gray-600">
We'll never share your email
</p>
)}
{errors.email && (
<p
id="email-error"
className="mt-1 text-sm text-red-500"
role="alert"
>
{errors.email}
</p>
)}
</div>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Subscribe
</button>
</form>
);
}Error summary for multiple errors:
export function FormErrorSummary({
errors,
}: {
errors: Record<string, string>;
}) {
if (Object.keys(errors).length === 0) return null;
return (
<div
role="alert"
aria-labelledby="error-summary-title"
className="mb-6 p-4 bg-red-50 border border-red-200 rounded"
tabIndex={-1}
ref={(el) => el?.focus()}
>
<h2 id="error-summary-title" className="font-bold text-red-800 mb-2">
There are {Object.keys(errors).length} errors in this form
</h2>
<ul className="list-disc list-inside">
{Object.entries(errors).map(([field, message]) => (
<li key={field}>
<a href={`#${field}`} className="text-red-700 underline">
{message}
</a>
</li>
))}
</ul>
</div>
);
}Consistent navigation#
// components/site-nav.tsx
export function SiteNav() {
return (
<nav aria-label="Main navigation">
<ul className="flex gap-4">
<li>
<a href="/" className="hover:underline">
Home
</a>
</li>
<li>
<a href="/blog" className="hover:underline">
Blog
</a>
</li>
<li>
<a href="/about" className="hover:underline">
About
</a>
</li>
</ul>
</nav>
);
}
// Multiple navs? Use labels
export function PageLayout() {
return (
<div>
<nav aria-label="Main navigation">{/* Primary site navigation */}</nav>
<aside>
<nav aria-label="Table of contents">
{/* Page-specific navigation */}
</nav>
</aside>
<footer>
<nav aria-label="Footer navigation">{/* Footer links */}</nav>
</footer>
</div>
);
}Robust: Works with assistive technologies#
Semantic HTML is your friend#
// ❌ Bad: Div soup
<div className="card">
<div className="title">Article Title</div>
<div className="meta">Published on Jan 1, 2026</div>
<div className="content">Article content...</div>
<div className="footer">
<div onClick={handleLike}>Like</div>
<div onClick={handleShare}>Share</div>
</div>
</div>
// ✅ Good: Semantic HTML
<article>
<header>
<h2>Article Title</h2>
<time dateTime="2026-01-01">Published on Jan 1, 2026</time>
</header>
<div className="content">
<p>Article content...</p>
</div>
<footer>
<button onClick={handleLike} aria-label="Like this article">
<HeartIcon /> Like
</button>
<button onClick={handleShare} aria-label="Share this article">
<ShareIcon /> Share
</button>
</footer>
</article>ARIA when HTML isn't enough#
// Live regions for dynamic content
export function LiveSearchResults({
query,
results,
}: {
query: string;
results: Array<{ id: string; title: string }>;
}) {
return (
<div>
<input
type="search"
value={query}
aria-label="Search"
aria-controls="search-results"
/>
<div
id="search-results"
role="region"
aria-live="polite"
aria-atomic="true"
>
{results.length > 0 ? (
<p className="sr-only">
Found {results.length} result{results.length !== 1 ? "s" : ""}
</p>
) : (
<p className="sr-only">No results found</p>
)}
<ul role="list">
{results.map((result) => (
<li key={result.id}>
<a href={`/posts/${result.id}`}>{result.title}</a>
</li>
))}
</ul>
</div>
</div>
);
}Custom components with proper ARIA#
"use client";
import { useState } from "react";
export function Accordion({
items,
}: {
items: Array<{ id: string; title: string; content: string }>;
}) {
const [expandedId, setExpandedId] = useState<string | null>(null);
return (
<div>
{items.map((item) => {
const isExpanded = expandedId === item.id;
return (
<div key={item.id} className="border-b">
<h3>
<button
onClick={() => setExpandedId(isExpanded ? null : item.id)}
aria-expanded={isExpanded}
aria-controls={`panel-${item.id}`}
className="w-full text-left px-4 py-3 font-medium flex justify-between items-center"
>
{item.title}
<span aria-hidden="true">{isExpanded ? "−" : "+"}</span>
</button>
</h3>
<div
id={`panel-${item.id}`}
role="region"
aria-labelledby={`button-${item.id}`}
hidden={!isExpanded}
className="px-4 py-3"
>
{item.content}
</div>
</div>
);
})}
</div>
);
}Testing for accessibility#
Automated testing#
npm install --save-dev @axe-core/react jest-axe// __tests__/accessibility.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { MyComponent } from '@/components/my-component';
expect.extend(toHaveNoViolations);
describe('MyComponent accessibility', () => {
it('should not have accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});Manual testing checklist#
// lib/a11y-checklist.ts
export const accessibilityChecklist = [
{
category: "Keyboard",
checks: [
"All interactive elements are keyboard accessible",
"Focus order is logical",
"Focus is visible",
"No keyboard traps",
"Skip link works",
],
},
{
category: "Screen Reader",
checks: [
"All images have alt text",
"Headings are hierarchical",
"Landmarks are properly labeled",
"Forms have labels",
"Errors are announced",
],
},
{
category: "Visual",
checks: [
"Color contrast meets WCAG AA (4.5:1)",
"Text is resizable to 200%",
"Content reflows at 320px width",
"No information conveyed by color alone",
],
},
{
category: "Content",
checks: [
"Language is set on html element",
"Page title is descriptive",
"Link text is meaningful",
"Error messages are clear",
],
},
];Testing with real assistive technologies#
macOS VoiceOver:
# Enable VoiceOver: Cmd + F5
# Navigate: Control + Option + Arrow keys
# Interact: Control + Option + SpaceWindows NVDA:
# Download from https://www.nvaccess.org/
# Navigate: Arrow keys
# Interact: Enter
# Read: Insert + Down arrowCommon patterns library#
// components/a11y/visually-hidden.tsx
export function VisuallyHidden({ children }: { children: React.ReactNode }) {
return <span className="sr-only">{children}</span>;
}
// components/a11y/focus-trap.tsx
import { useEffect, useRef } from "react";
export function useFocusTrap<T extends HTMLElement>() {
const ref = useRef<T>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const focusableElements = element.querySelectorAll(
"a[href], button:not([disabled]), textarea, input, select",
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[
focusableElements.length - 1
] as HTMLElement;
function handleTab(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();
}
}
}
element.addEventListener("keydown", handleTab);
firstElement?.focus();
return () => element.removeEventListener("keydown", handleTab);
}, []);
return ref;
}
// components/a11y/announce.tsx
export function LiveRegion({
message,
priority = "polite",
}: {
message: string;
priority?: "polite" | "assertive";
}) {
return (
<div
role="status"
aria-live={priority}
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
}The business case#
Accessibility isn't just ethical—it's profitable:
- Better SEO: Semantic HTML and alt text help search engines
- Wider audience: 15% more potential users
- Legal compliance: Avoid ADA lawsuits (average settlement: $245,000)
- Better UX for everyone: Keyboard shortcuts, clear focus, good contrast help all users
Your implementation roadmap#
Week 1: Low-hanging fruit
- Add alt text to all images
- Ensure all buttons/links are keyboard accessible
- Fix color contrast issues
Week 2: Forms and navigation
- Add labels to all form inputs
- Implement skip links
- Test keyboard navigation flow
Week 3: Dynamic content
- Add ARIA live regions
- Implement focus management in modals
- Test with screen reader
Week 4: Testing and documentation
- Set up automated a11y tests
- Document patterns in component library
- Create accessibility statement page
Resources to go deeper#
Accessibility is a journey#
You don't need to be perfect on day one. Start with the basics:
- Use semantic HTML
- Make it keyboard accessible
- Add text alternatives
- Test with real users
Every improvement makes your product better for everyone. That's worth celebrating.
What accessibility feature are you most excited to implement? Let's build a more inclusive web together.
