Building Accessible Web Applications
Create inclusive web experiences that work for everyone, regardless of abilities.

Accessibility is not a checklist. It is the practice of building products that work for the widest possible range of people — including those who use screen readers, keyboard navigation, voice control, high-contrast displays, or who have cognitive or motor differences. Beyond the ethical case, accessible sites rank better in SEO, perform better on mobile, and are often required by law (ADA, EN 301 549, EAA). This guide gives you the practical foundation.
WCAG and POUR Principles
The Web Content Accessibility Guidelines (WCAG) organise accessibility into four principles, commonly called POUR:
| Principle | Meaning |
|---|---|
| Perceivable | Information can be seen or heard — not just one sense |
| Operable | Interface can be used without a mouse |
| Understandable | Content is readable and behaviour is predictable |
| Robust | Works with current and future assistive technologies |
WCAG has three conformance levels: A (minimum), AA (standard target for most sites), and AAA (enhanced). Aim for AA as your baseline. Many accessibility laws reference WCAG 2.1 AA specifically.
Semantic HTML: The Foundation
Semantic HTML is the highest-leverage accessibility improvement you can make. Browser accessibility APIs are built on top of it — screen readers, voice control, and keyboard navigation all work correctly with semantic markup for free.
<!-- Bad — div soup tells assistive tech nothing -->
<div class="header">
<div class="nav">
<div class="nav-item" onclick="navigate('/blog')">Blog</div>
</div>
</div>
<div class="main-content">
<div class="heading">Article Title</div>
<div class="body-text">Content…</div>
</div>
<!-- Good — structure is meaningful -->
<header>
<nav aria-label="Main navigation">
<ul>
<li><a href="/blog">Blog</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h1>Article Title</h1>
<p>Content…</p>
</article>
</main>
Use elements for their purpose:
<button>for interactive controls (not<div onclick>).<a href>for navigation,<button>for actions.<nav>,<main>,<header>,<footer>,<aside>as landmark regions.<h1>–<h6>for a logical heading outline — one<h1>per page, headings that don’t skip levels.<label>paired with every form input.
Keyboard Navigation
Every interactive element must be reachable and operable by keyboard. Tab moves focus forward; Shift+Tab moves it backward; Enter/Space activates buttons and links.
// Bad — div with click handler is not keyboard accessible
<div onClick={handleDelete} className="delete-btn">
Delete
</div>
// Good — button is focusable and activates on Enter/Space automatically
<button onClick={handleDelete} type="button">
Delete
</button>
Focus Management
When content changes dynamically — dialogs open, routes change, errors appear — move focus to the relevant element:
// ErrorMessage.jsx — an interactive island
import { useEffect, useRef } from "react";
function ErrorMessage({ message }: { message: string }) {
const ref = useRef<HTMLParagraphElement>(null);
useEffect(() => {
ref.current?.focus();
}, [message]);
return (
<p
ref={ref}
role="alert"
tabIndex={-1} // focusable programmatically but not in tab order
className="text-red-600"
>
{message}
</p>
);
}
Custom Focus Styles
Never remove the focus outline with outline: none unless you replace it with something equally visible:
/* Bad */
:focus {
outline: none;
}
/* Good — custom, visible focus ring */
:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 3px;
border-radius: 2px;
}
Use :focus-visible instead of :focus — it shows only when keyboard-navigating, not on mouse click.
ARIA Attributes
ARIA (Accessible Rich Internet Applications) supplements HTML semantics for dynamic and complex widgets. The first rule of ARIA: don’t use ARIA if a native HTML element works.
Common ARIA Patterns
<!-- Labels for icon-only buttons -->
<button aria-label="Close dialog" type="button">
<svg aria-hidden="true" focusable="false">…</svg>
</button>
<!-- Expand/collapse pattern -->
<button aria-expanded="false" aria-controls="panel-1" type="button">
Show details
</button>
<div id="panel-1" hidden>…</div>
<!-- Live regions — announce dynamic changes to screen readers -->
<div role="status" aria-live="polite" aria-atomic="true">{statusMessage}</div>
<!-- Error on a form field -->
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-describedby="email-error"
aria-invalid="{hasError}"
/>
{hasError && (
<p id="email-error" role="alert">Please enter a valid email address.</p>
)}
Modal Dialog
Dialogs require focus trapping — focus should cycle within the dialog while it is open:
// Modal.jsx — an interactive island
import { useEffect, useRef } from "react";
function Modal({ isOpen, onClose, title, children }) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
dialogRef.current?.focus();
}
}, [isOpen]);
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
if (isOpen) document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
ref={dialogRef}
tabIndex={-1}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
>
<div
className="rounded-lg bg-white p-6 shadow-xl"
style={{ maxWidth: "480px", width: "100%" }}
>
<h2 id="dialog-title" className="text-lg font-semibold">
{title}
</h2>
{children}
<button onClick={onClose} type="button">
Close
</button>
</div>
</div>
);
}
For production use, consider Radix UI or Headless UI — they implement these patterns correctly and are well-tested by the community.
Colour and Contrast
WCAG AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (18pt+) and UI components like buttons and inputs.
Use the WebAIM Contrast Checker or the browser’s DevTools accessibility panel to verify.
/* Common failures */
color: #999; on white background /* 2.85:1 — fails */
color: #767676; on white /* 4.54:1 — barely passes AA */
color: #595959; on white /* 7.0:1 — passes AAA */
Don’t convey information by colour alone. Use icons, patterns, or text alongside colour:
// Bad — only colour distinguishes error from success
<span className="text-red-500">{message}</span>
// Good — icon + colour + text
<span className="flex items-center gap-1 text-red-600">
<AlertCircleIcon aria-hidden="true" />
<span role="alert">{message}</span>
</span>
Images and Media
Every meaningful image needs descriptive alt text. Decorative images get an empty alt so screen readers skip them:
<!-- Meaningful image -->
<img
src="/images/deployment-diagram.png"
alt="Diagram showing requests flowing from browser through CDN to origin server"
/>
<!-- Decorative — empty alt, screen reader skips it -->
<img src="/images/decorative-squiggle.svg" alt="" aria-hidden="true" />
<!-- Icon with adjacent text — hide from screen readers -->
<button>
<svg aria-hidden="true" focusable="false">…</svg>
Download PDF
</button>
For videos, provide captions (not auto-generated) and a text transcript. Audio descriptions for content that is only conveyed visually.
Forms
Forms are where accessibility failure hurts most — errors that can’t be understood, fields that aren’t labelled, and focus management that abandons the user.
function ContactForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const firstErrorRef = useRef<HTMLElement>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const newErrors = validate(e.currentTarget as HTMLFormElement);
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) {
// Move focus to the first error so screen readers announce it
firstErrorRef.current?.focus();
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="name">
Full name
<span aria-hidden="true"> *</span>
<span className="sr-only"> (required)</span>
</label>
<input
id="name"
type="text"
required
autoComplete="name"
aria-describedby={errors.name ? "name-error" : undefined}
aria-invalid={!!errors.name}
/>
{errors.name && (
<p
id="name-error"
role="alert"
ref={firstErrorRef as any}
tabIndex={-1}
>
{errors.name}
</p>
)}
</div>
<button type="submit">Send message</button>
</form>
);
}
Skip Navigation Links
Allow keyboard users to jump past repeated navigation:
<!-- First element in <body> -->
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:rounded focus:bg-white focus:px-4 focus:py-2"
>
Skip to main content
</a>
<!-- … navigation … -->
<main id="main-content" tabindex="-1">
<!-- page content -->
</main>
Invisible by default, visible when focused by a keyboard user.
Testing for Accessibility
Automated tools
npm install -D jest-axe @axe-core/react
// In tests (catches ~30% of WCAG issues)
import { axe } from "jest-axe";
test("ContactForm has no axe violations", async () => {
const { container } = render(<ContactForm />);
expect(await axe(container)).toHaveNoViolations();
});
Browser extensions for manual audit: axe DevTools, WAVE, Accessibility Insights. Run them on every page before you ship.
Screen Reader Testing
| OS | Screen Reader | Browser |
|---|---|---|
| macOS | VoiceOver (Cmd+F5) | Safari |
| Windows | NVDA (free) | Chrome / Firefox |
| Windows | JAWS | Chrome / Firefox |
| iOS | VoiceOver | Safari |
| Android | TalkBack | Chrome |
Manual testing checklist:
- Navigate the page using only Tab and arrow keys.
- All interactive elements reachable and usable?
- Dialogs trap focus and close on Escape?
- Dynamic content changes (loading, errors) announced?
- Images have meaningful alt text?
Keyboard Navigation Checklist
✓ Tab reaches all interactive elements
✓ Focus order is logical (matches visual order)
✓ Focus indicator is visible at all times
✓ Dialogs trap focus
✓ Escape closes dialogs and menus
✓ Dropdowns navigable with arrow keys
✓ Skip navigation link present

Accessible Component Libraries
Building accessible custom components correctly (dialogs, comboboxes, date pickers) is genuinely hard. Use tested primitives:
- Radix UI — unstyled, fully accessible primitives.
- Headless UI — Tailwind’s accessible component collection.
- React Aria — Adobe’s accessible hook library.
- shadcn/ui — styled Radix UI components, copy-paste into your project.
Legal Considerations
Many jurisdictions require accessible websites:
- USA — ADA (Americans with Disabilities Act), Section 508 for federal sites.
- EU — EN 301 549, EU Accessibility Act (EAA) effective 2025.
- UK — Equality Act 2010, Public Sector Bodies Accessibility Regulations.
WCAG 2.1 AA is the referenced standard in most regulations. Failing to meet it has resulted in costly litigation — Target, Domino’s, and many others have faced judgments.
Closing Thoughts
Start with semantic HTML — it gives you keyboard support, screen reader announcements, and logical structure almost for free. Add ARIA only where HTML semantics fall short. Test with an actual keyboard and at least one screen reader before you consider a feature done. Accessibility is not a phase of the project; it is a habit that becomes invisible once you build it in from the start.
About the author
The Plake Team
Writes long-form essays on craft, software, and the slow web. Curates the Sunday digest read by 88,000+ readers.
Read moreKeep reading
Related Articles







