Accessible websites work for everyone. Here's how to build inclusive web applications.
Semantic HTML#
1<!-- ❌ Non-semantic -->
2<div class="header">
3 <div class="nav">
4 <div class="nav-item">Home</div>
5 </div>
6</div>
7<div class="main">
8 <div class="article">
9 <div class="title">Article Title</div>
10 </div>
11</div>
12
13<!-- ✓ Semantic -->
14<header>
15 <nav aria-label="Main navigation">
16 <a href="/">Home</a>
17 </nav>
18</header>
19<main>
20 <article>
21 <h1>Article Title</h1>
22 </article>
23</main>Headings Structure#
1<!-- ✓ Proper heading hierarchy -->
2<h1>Page Title</h1>
3 <h2>Section 1</h2>
4 <h3>Subsection 1.1</h3>
5 <h3>Subsection 1.2</h3>
6 <h2>Section 2</h2>
7 <h3>Subsection 2.1</h3>
8
9<!-- ❌ Skipping levels -->
10<h1>Page Title</h1>
11 <h3>This skips h2!</h3>Links and Buttons#
1<!-- Links navigate somewhere -->
2<a href="/about">About Us</a>
3
4<!-- Buttons perform actions -->
5<button type="button" onclick="toggleMenu()">Menu</button>
6
7<!-- ❌ Bad: div as button -->
8<div onclick="submit()">Submit</div>
9
10<!-- ✓ Good: actual button -->
11<button type="submit">Submit</button>
12
13<!-- Links with context -->
14<!-- ❌ Bad -->
15<a href="/article">Read more</a>
16
17<!-- ✓ Good -->
18<a href="/article">Read more about accessibility</a>
19
20<!-- Or use aria-label -->
21<a href="/article" aria-label="Read more about accessibility">
22 Read more
23</a>Images and Alt Text#
1<!-- Informative images need alt text -->
2<img src="chart.png" alt="Sales increased 25% in Q4 2024">
3
4<!-- Decorative images use empty alt -->
5<img src="decorative-border.png" alt="">
6
7<!-- Complex images -->
8<figure>
9 <img src="data-visualization.png" alt="Quarterly revenue by region">
10 <figcaption>
11 Figure 1: Revenue breakdown showing North America leads with 45%,
12 Europe at 30%, and Asia-Pacific at 25%.
13 </figcaption>
14</figure>
15
16<!-- Icon buttons need accessible names -->
17<button aria-label="Close dialog">
18 <svg><!-- close icon --></svg>
19</button>Forms#
1<!-- Labels must be associated -->
2<label for="email">Email Address</label>
3<input type="email" id="email" name="email" required>
4
5<!-- Or wrap input -->
6<label>
7 Email Address
8 <input type="email" name="email" required>
9</label>
10
11<!-- Required fields -->
12<label for="name">
13 Name <span aria-hidden="true">*</span>
14 <span class="sr-only">(required)</span>
15</label>
16<input type="text" id="name" required aria-required="true">
17
18<!-- Error messages -->
19<label for="password">Password</label>
20<input
21 type="password"
22 id="password"
23 aria-describedby="password-error password-hint"
24 aria-invalid="true"
25>
26<span id="password-hint">Must be at least 8 characters</span>
27<span id="password-error" role="alert">Password is too short</span>
28
29<!-- Fieldsets for groups -->
30<fieldset>
31 <legend>Shipping Address</legend>
32 <label for="street">Street</label>
33 <input type="text" id="street">
34 <!-- more fields -->
35</fieldset>ARIA Attributes#
1<!-- Roles -->
2<div role="alert">Form submitted successfully!</div>
3<div role="navigation" aria-label="Breadcrumb">...</div>
4<ul role="tablist">...</ul>
5
6<!-- States -->
7<button aria-expanded="false" aria-controls="menu">Menu</button>
8<div id="menu" aria-hidden="true">...</div>
9
10<input type="checkbox" aria-checked="true">
11<button aria-pressed="true">Bold</button>
12<div aria-busy="true">Loading...</div>
13
14<!-- Properties -->
15<input aria-label="Search">
16<div aria-describedby="help-text">...</div>
17<section aria-labelledby="section-heading">
18 <h2 id="section-heading">Features</h2>
19</section>
20
21<!-- Live regions -->
22<div aria-live="polite">Status updates appear here</div>
23<div aria-live="assertive">Critical alerts here</div>Keyboard Navigation#
1// Focus management
2function openModal() {
3 const modal = document.getElementById('modal');
4 modal.style.display = 'block';
5
6 // Save previous focus
7 const previousFocus = document.activeElement;
8
9 // Focus first focusable element
10 const firstFocusable = modal.querySelector(
11 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
12 );
13 firstFocusable?.focus();
14
15 // Trap focus in modal
16 modal.addEventListener('keydown', (e) => {
17 if (e.key === 'Tab') {
18 trapFocus(e, modal);
19 }
20 if (e.key === 'Escape') {
21 closeModal(previousFocus);
22 }
23 });
24}
25
26function trapFocus(e: KeyboardEvent, container: HTMLElement) {
27 const focusables = container.querySelectorAll(
28 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
29 );
30 const first = focusables[0] as HTMLElement;
31 const last = focusables[focusables.length - 1] as HTMLElement;
32
33 if (e.shiftKey && document.activeElement === first) {
34 e.preventDefault();
35 last.focus();
36 } else if (!e.shiftKey && document.activeElement === last) {
37 e.preventDefault();
38 first.focus();
39 }
40}1<!-- Skip link -->
2<a href="#main-content" class="skip-link">
3 Skip to main content
4</a>
5
6<style>
7.skip-link {
8 position: absolute;
9 top: -40px;
10 left: 0;
11 padding: 8px;
12 background: #000;
13 color: #fff;
14 z-index: 100;
15}
16
17.skip-link:focus {
18 top: 0;
19}
20</style>
21
22<main id="main-content" tabindex="-1">
23 <!-- Main content -->
24</main>Color and Contrast#
1/* Minimum contrast ratios */
2/* Normal text: 4.5:1 */
3/* Large text (18pt+): 3:1 */
4
5/* ❌ Insufficient contrast */
6.bad {
7 color: #999;
8 background: #fff; /* 2.85:1 ratio */
9}
10
11/* ✓ Sufficient contrast */
12.good {
13 color: #595959;
14 background: #fff; /* 7:1 ratio */
15}
16
17/* Don't rely on color alone */
18.error {
19 color: red;
20 /* Also add icon or text */
21}
22
23.error::before {
24 content: "⚠ ";
25}Screen Reader Only Text#
1/* Visually hidden but accessible */
2.sr-only {
3 position: absolute;
4 width: 1px;
5 height: 1px;
6 padding: 0;
7 margin: -1px;
8 overflow: hidden;
9 clip: rect(0, 0, 0, 0);
10 white-space: nowrap;
11 border: 0;
12}
13
14/* Focusable when needed */
15.sr-only-focusable:focus {
16 position: static;
17 width: auto;
18 height: auto;
19 overflow: visible;
20 clip: auto;
21 white-space: normal;
22}1<button>
2 <svg><!-- icon --></svg>
3 <span class="sr-only">Delete item</span>
4</button>
5
6<a href="/cart">
7 Cart
8 <span class="sr-only">(3 items)</span>
9</a>React Accessibility#
1// Focus management in React
2import { useRef, useEffect } from 'react';
3
4function Modal({ isOpen, onClose, children }) {
5 const modalRef = useRef<HTMLDivElement>(null);
6 const previousFocus = useRef<HTMLElement | null>(null);
7
8 useEffect(() => {
9 if (isOpen) {
10 previousFocus.current = document.activeElement as HTMLElement;
11 modalRef.current?.focus();
12 } else {
13 previousFocus.current?.focus();
14 }
15 }, [isOpen]);
16
17 if (!isOpen) return null;
18
19 return (
20 <div
21 ref={modalRef}
22 role="dialog"
23 aria-modal="true"
24 aria-labelledby="modal-title"
25 tabIndex={-1}
26 onKeyDown={(e) => e.key === 'Escape' && onClose()}
27 >
28 <h2 id="modal-title">Modal Title</h2>
29 {children}
30 <button onClick={onClose}>Close</button>
31 </div>
32 );
33}
34
35// Announce dynamic content
36function LiveRegion() {
37 const [message, setMessage] = useState('');
38
39 const announce = (text: string) => {
40 setMessage(text);
41 setTimeout(() => setMessage(''), 1000);
42 };
43
44 return (
45 <div aria-live="polite" aria-atomic="true" className="sr-only">
46 {message}
47 </div>
48 );
49}Testing Accessibility#
1// Jest with jest-axe
2import { render } from '@testing-library/react';
3import { axe, toHaveNoViolations } from 'jest-axe';
4
5expect.extend(toHaveNoViolations);
6
7test('form is accessible', async () => {
8 const { container } = render(<LoginForm />);
9 const results = await axe(container);
10 expect(results).toHaveNoViolations();
11});
12
13// Playwright accessibility testing
14import { test, expect } from '@playwright/test';
15import AxeBuilder from '@axe-core/playwright';
16
17test('home page passes axe', async ({ page }) => {
18 await page.goto('/');
19
20 const results = await new AxeBuilder({ page }).analyze();
21
22 expect(results.violations).toEqual([]);
23});Checklist#
Structure:
✓ Semantic HTML elements
✓ Proper heading hierarchy
✓ Skip links provided
✓ Landmarks defined
Interactive:
✓ Keyboard accessible
✓ Focus visible
✓ Focus trapped in modals
✓ Touch targets 44px+
Content:
✓ Alt text for images
✓ Form labels associated
✓ Error messages clear
✓ Links descriptive
Visual:
✓ Color contrast sufficient
✓ Text resizable to 200%
✓ No content in images only
✓ Motion reducible
Conclusion#
Accessibility benefits everyone, not just users with disabilities. Use semantic HTML, ensure keyboard navigation works, provide sufficient contrast, and test with real assistive technologies. Building accessible from the start is easier than retrofitting later.