Building Accessible Web Applications
Best practices for creating inclusive web experiences that work for everyone.
Why Accessibility Matters
Accessibility isn't just about compliance or checking boxes—it's about creating web experiences that work for everyone, regardless of their abilities. Here's why it should be a priority:
- 1 billion+ people worldwide have disabilities
- Good accessibility = better UX for everyone
- Legal requirement in many jurisdictions
- Better SEO (semantic HTML helps search engines)
- Larger audience reach
The POUR Principles
WCAG is built on four principles. Content must be:
- Perceivable - Users can perceive the information
- Operable - Users can operate the interface
- Understandable - Users can understand the information
- Robust - Content works across technologies
Practical Implementation
1. Semantic HTML
Use the right HTML elements for the job:
<!-- ❌ Bad -->
<div onclick="handleClick()">Click me</div>
<!-- ✅ Good -->
<button onClick={handleClick}>Click me</button>
<!-- ❌ Bad -->
<div class="heading">Page Title</div>
<!-- ✅ Good -->
<h1>Page Title</h1>
Why it matters:
- Screen readers use semantic HTML for navigation
- Keyboard navigation works automatically
- Better SEO and document structure
2. Keyboard Navigation
Every interactive element must be keyboard accessible:
function AccessibleDropdown() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button
onClick={() => setIsOpen(!isOpen)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsOpen(!isOpen);
}
}}
aria-expanded={isOpen}
aria-haspopup="true"
>
Menu
</button>
{isOpen && (
<ul role="menu">
<li role="menuitem">
<a href="/home">Home</a>
</li>
<li role="menuitem">
<a href="/about">About</a>
</li>
</ul>
)}
</div>
);
}
Test it: Tab through your entire site without a mouse.
3. Color Contrast
Ensure sufficient contrast between text and background:
/* ❌ Bad - Contrast ratio 2.1:1 */
.text {
color: #999;
background: #fff;
}
/* ✅ Good - Contrast ratio 7:1 */
.text {
color: #333;
background: #fff;
}
/* ✅ Good - Contrast ratio 12:1 */
.text-dark {
color: #fff;
background: #0b0d10;
}
WCAG Requirements:
- Normal text: 4.5:1 minimum (AA)
- Large text (18pt+): 3:1 minimum (AA)
- Enhanced: 7:1 (AAA)
Tool: Use WebAIM Contrast Checker
4. Alternative Text
Provide meaningful alt text for images:
// ❌ Bad
<img src="chart.png" alt="chart" />
// ✅ Good - Descriptive
<img
src="chart.png"
alt="Bar chart showing 45% increase in revenue from Q1 to Q2 2024"
/>
// ✅ Decorative images
<img src="decoration.png" alt="" role="presentation" />
// ✅ Complex images
<figure>
<img
src="complex-chart.png"
alt="Detailed sales data chart"
/>
<figcaption>
Sales increased 45% from $2M in Q1 to $2.9M in Q2 2024.
Top performing region was Asia Pacific with 68% growth.
</figcaption>
</figure>
5. ARIA Labels and Roles
Use ARIA to enhance semantics when HTML isn't enough:
// Icon buttons need labels
<button aria-label="Close menu">
<X aria-hidden="true" />
</button>
// Form inputs need labels
<div>
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
aria-required="true"
aria-describedby="email-hint"
/>
<span id="email-hint">
We'll never share your email
</span>
</div>
// Loading states
<button disabled aria-busy="true">
<Spinner aria-hidden="true" />
Loading...
</button>
// Live regions for dynamic content
<div aria-live="polite" aria-atomic="true">
{message}
</div>
6. Focus Management
Make focus visible and manage it properly:
/* ✅ Clear focus indicators */
:focus-visible {
outline: 2px solid #22d3ee;
outline-offset: 2px;
border-radius: 4px;
}
/* ❌ Never do this without an alternative */
:focus {
outline: none; /* Removes focus indicator! */
}
// Focus management in modals
function Modal({ isOpen, onClose, children }: ModalProps) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) {
// Focus close button when modal opens
closeButtonRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<h2 id="modal-title">Modal Title</h2>
{children}
<button
ref={closeButtonRef}
onClick={onClose}
>
Close
</button>
</div>
);
}
7. Skip Links
Allow keyboard users to skip navigation:
// Add at the very top of your app
export function Layout({ children }) {
return (
<>
<a
href="#main-content"
className="skip-link"
>
Skip to main content
</a>
<header>
{/* Navigation */}
</header>
<main id="main-content">
{children}
</main>
</>
);
}
.skip-link {
position: absolute;
left: -9999px;
z-index: 999;
padding: 1rem;
background: #22d3ee;
color: #0b0d10;
text-decoration: none;
}
.skip-link:focus {
left: 50%;
transform: translateX(-50%);
top: 1rem;
}
Form Accessibility
Forms are critical—make them accessible:
function AccessibleForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
return (
<form>
<div>
<label htmlFor="name">
Name *
</label>
<input
id="name"
type="text"
required
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "name-error" : undefined}
/>
{errors.name && (
<span
id="name-error"
role="alert"
className="error"
>
{errors.name}
</span>
)}
</div>
<fieldset>
<legend>Subscription Type</legend>
<div>
<input
type="radio"
id="free"
name="plan"
value="free"
/>
<label htmlFor="free">Free</label>
</div>
<div>
<input
type="radio"
id="pro"
name="plan"
value="pro"
/>
<label htmlFor="pro">Pro</label>
</div>
</fieldset>
<button type="submit">
Submit
</button>
</form>
);
}
Testing for Accessibility
Automated Testing
# Install axe-core
npm install --save-dev @axe-core/react
// In development
if (process.env.NODE_ENV !== 'production') {
import('@axe-core/react').then((axe) => {
axe.default(React, ReactDOM, 1000);
});
}
Manual Testing Checklist
Essential Tests:
- ✅ Keyboard only - Can you navigate the entire site with just keyboard?
- ✅ Screen reader - Test with NVDA (Windows) or VoiceOver (Mac)
- ✅ Zoom to 200% - Does the site still work?
- ✅ Color blindness - Use a simulator
- ✅ Automated tools - Run Lighthouse, axe DevTools
- ✅ Reduce motion - Test with
prefers-reduced-motion
Respect User Preferences
/* Reduce motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* High contrast mode */
@media (prefers-contrast: high) {
.card {
border: 2px solid currentColor;
}
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b0d10;
--fg: #e6eaf2;
}
}
Common Mistakes to Avoid
1. Clickable Divs
// ❌ Bad - Not keyboard accessible, no semantics
<div onClick={handleClick} className="button">
Click me
</div>
// ✅ Good
<button onClick={handleClick}>
Click me
</button>
2. Missing Labels
// ❌ Bad
<input type="text" placeholder="Enter name" />
// ✅ Good
<label htmlFor="name">Name</label>
<input id="name" type="text" placeholder="Enter name" />
3. Auto-Playing Media
// ❌ Bad - Violates WCAG
<video autoplay />
// ✅ Good - User controls
<video controls>
<source src="video.mp4" type="video/mp4" />
<track
kind="captions"
src="captions.vtt"
srclang="en"
label="English"
/>
</video>
4. Inaccessible Modals
// ❌ Bad - Focus not trapped, no aria
<div className="modal">
<div>Content</div>
</div>
// ✅ Good - Proper modal
function AccessibleModal({ isOpen, onClose, children }) {
useEffect(() => {
if (!isOpen) return;
// Trap focus
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
function handleTab(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
document.addEventListener('keydown', handleTab);
return () => document.removeEventListener('keydown', handleTab);
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<h2 id="modal-title">Title</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
);
}
Resources
- WCAG Guidelines: w3.org/WAI/WCAG21/quickref
- MDN Accessibility: developer.mozilla.org/en-US/docs/Web/Accessibility
- WebAIM: webaim.org
- The A11y Project: a11yproject.com
Conclusion
Building accessible websites isn't optional—it's essential. Start with these fundamentals:
- Use semantic HTML
- Ensure keyboard accessibility
- Provide text alternatives
- Test with real assistive technologies
- Make it a team priority
Remember: Accessibility benefits everyone, not just people with disabilities. A more accessible web is a better web.
Questions about accessibility? Reach out - I'm happy to help!