Web Accessibility: Building Interfaces Everyone Can Use
Learn how to build accessible web applications that meet WCAG 2.2 standards. Covers semantic HTML, keyboard navigation, ARIA patterns, color contrast, and automated accessibility testing.

Introduction#
Accessibility is not a feature. It is a quality attribute of software, like performance or security. An interface that is inaccessible to a user with a disability is, for that user, completely broken. Yet in most engineering teams, accessibility is treated as an afterthought: something added at the end of a project cycle, after the design is finalized and the code is written.
This approach is both ethically wrong and technically expensive. Retrofitting accessibility into an existing codebase costs significantly more than building it in from the start. More importantly, the patterns that make interfaces accessible, semantic HTML, keyboard operability, clear focus management, and sufficient color contrast, make interfaces better for everyone, not just users with disabilities.
This guide covers the technical foundations of web accessibility, grounded in WCAG 2.2 and ARIA Authoring Practices, with practical code examples that reflect how accessibility is implemented in production applications.
1. The Foundation: Semantic HTML#
The most impactful accessibility improvement most codebases can make costs nothing: use the correct HTML element for the job. Semantic HTML communicates structure and meaning to assistive technologies without any additional code.
A button implemented as a div with a click handler requires a developer to manually implement everything a native button provides for free: keyboard focus, Enter and Space key activation, role announcement to screen readers, and disabled state handling. The same logic applies to every interactive and structural element.
The Semantic HTML Checklist#
Use button for actions and a for navigation. Never use div or span for either.
Use heading elements (h1 through h6) to create a logical document outline. Do not skip levels.
Use nav for navigation landmarks, main for the primary content, aside for supplementary content, and footer for page footer.
Use ul and ol for lists of items. Do not style div elements to look like lists.
Use table with th, caption, and scope attributes for tabular data.
Use label elements associated with every form input via the for attribute or by wrapping the input.
<!-- Wrong: requires manual ARIA and keyboard handling -->
<div onclick="submitForm()">Submit</div>
<!-- Correct: all behavior provided by the browser -->
<button type="submit">Submit</button>
<!-- Wrong: no label association -->
<p>Email</p>
<input type="email" />
<!-- Correct: explicit label association -->
<label for="email">Email</label>
<input id="email" type="email" />
2. Keyboard Navigation#
Every interactive element on a page must be operable with a keyboard alone. This is a hard requirement of WCAG 2.1 Success Criterion 2.1.1. Users who cannot use a mouse, including users with motor disabilities and power users who prefer keyboard navigation, depend on this.
Focus Management Principles#
Never remove focus outlines without providing an alternative. outline: none without a replacement is one of the most harmful CSS declarations in widespread use.
Focus order must be logical. The tab order should follow the visual reading order of the page. Avoid tabindex values greater than 0, which artificially modify tab order.
Focus must be managed programmatically when content changes dynamically. Opening a modal should move focus to the modal. Closing it should return focus to the element that opened it.
// Focus management for a modal dialog
function Modal({ open, onClose, triggerRef, children }) {
const modalRef = useRef(null);
useEffect(() => {
if (open) {
// Move focus into the modal when it opens
modalRef.current?.focus();
}
}, [open]);
function handleClose() {
onClose();
// Return focus to the trigger when modal closes
triggerRef.current?.focus();
}
if (!open) return null;
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
onKeyDown={(e) => e.key === "Escape" && handleClose()}
>
{children}
<button onClick={handleClose}>Close</button>
</div>
);
}
Focus Trapping#
Modal dialogs, drawers, and other overlay patterns must trap focus within the overlay while they are open. A user pressing Tab inside a modal should cycle through the focusable elements within the modal only, not the elements behind it.
function useFocusTrap(containerRef, active) {
useEffect(() => {
if (!active) return;
const focusableSelectors = [
"a[href]", "button:not([disabled])", "input:not([disabled])",
"select:not([disabled])", "textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(", ");
function handleKeyDown(e) {
if (e.key !== "Tab") return;
const focusable = Array.from(
containerRef.current?.querySelectorAll(focusableSelectors) ?? []
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last?.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [active, containerRef]);
}
3. ARIA: When and How to Use It#
ARIA (Accessible Rich Internet Applications) attributes communicate semantic information that HTML alone cannot express. The first rule of ARIA is: do not use ARIA if native HTML provides the same semantics. ARIA should extend HTML, not replace it.
Essential ARIA Patterns#
Labeling#
<!-- aria-label: provides a text label directly -->
<button aria-label="Close dialog">
<XIcon aria-hidden="true" />
</button>
<!-- aria-labelledby: references another element as the label -->
<section aria-labelledby="section-heading">
<h2 id="section-heading">Recent Orders</h2>
...
</section>
<!-- aria-describedby: provides additional descriptive text -->
<input
type="password"
aria-describedby="password-hint"
/>
<p id="password-hint">Must be at least 8 characters with one number.</p>
Live Regions#
When content updates dynamically without a page navigation, screen readers need to be told to announce the change. Live regions handle this.
<!-- Status messages: polite (waits for current speech to finish) -->
<div role="status" aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
<!-- Error messages: assertive (interrupts current speech) -->
<div role="alert" aria-live="assertive">
{errorMessage}
</div>
Expandable Components#
function Disclosure({ title, children }) {
const [open, setOpen] = useState(false);
const contentId = useId();
return (
<div>
<button
aria-expanded={open}
aria-controls={contentId}
onClick={() => setOpen(!open)}
>
{title}
</button>
<div id={contentId} hidden={!open}>
{children}
</div>
</div>
);
}
4. Color and Visual Design#
Color is the most common source of accessibility failures in visual design. Two requirements from WCAG 2.2 apply to nearly every UI element.
Color Contrast#
Text must achieve a contrast ratio of at least 4.5 against its background for normal text, and 3 for large text (18pt or 14pt bold). Interactive elements such as button borders, input outlines, and focus indicators must achieve 3 against adjacent colors.
Tools like the Chrome DevTools Accessibility panel, axe DevTools, and the WebAIM Contrast Checker make it straightforward to verify contrast ratios during development. The most effective approach is to bake contrast verification into the design token system, so tokens that fail contrast requirements cannot be combined in the design tool.
Color as the Only Differentiator#
WCAG Success Criterion 1.4.1 states that color must not be the only visual means of conveying information. A form field that turns red to indicate an error must also show a text error message or an icon. A chart that uses color to distinguish data series must also use patterns, labels, or shapes.
<!-- Wrong: color alone indicates error -->
<input className="border-red-500" />
<!-- Correct: color plus text plus icon -->
<input
className="border-red-500"
aria-invalid="true"
aria-describedby="email-error"
/>
<p id="email-error" className="text-red-600">
<ErrorIcon aria-hidden="true" />
Please enter a valid email address.
</p>
5. Accessible Forms#
Forms are the highest-stakes accessibility surface in most web applications. An inaccessible form can prevent a user from completing a purchase, submitting an application, or accessing a service entirely.
function AccessibleForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
const nameErrorId = useId();
const emailErrorId = useId();
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="name">
Full Name <span aria-hidden="true">*</span>
<span className="sr-only">(required)</span>
</label>
<input
id="name"
type="text"
autoComplete="name"
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? nameErrorId : undefined}
{...register("name", { required: "Full name is required" })}
/>
{errors.name && (
<p id={nameErrorId} role="alert">{errors.name.message}</p>
)}
</div>
<div>
<label htmlFor="email">
Email Address <span aria-hidden="true">*</span>
<span className="sr-only">(required)</span>
</label>
<input
id="email"
type="email"
autoComplete="email"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? emailErrorId : undefined}
{...register("email", {
required: "Email address is required",
pattern: { value: /\S+@\S+\.\S+/, message: "Enter a valid email" }
})}
/>
{errors.email && (
<p id={emailErrorId} role="alert">{errors.email.message}</p>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}
6. Testing Accessibility#
Automated tools catch approximately 30 to 40 percent of accessibility issues. Manual testing with a keyboard and a screen reader is required to catch the rest. A comprehensive testing strategy uses both.
Automated Testing with axe#
// vitest / jest with axe
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import { LoginForm } from "./LoginForm";
expect.extend(toHaveNoViolations);
it("has no accessibility violations", async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Manual Testing Checklist#
Navigate the entire page using only the Tab, Shift+Tab, Enter, Space, and Arrow keys
Verify that all interactive elements are reachable and operable by keyboard
Test with VoiceOver (macOS and iOS), NVDA or JAWS (Windows), and TalkBack (Android)
Zoom the browser to 200 percent and verify the layout does not break
Test with Windows High Contrast Mode enabled
Verify all form errors are announced to screen readers
Conclusion#
Accessible interfaces are not a separate category of software. They are simply software that is built correctly. The techniques in this guide, semantic HTML, keyboard operability, ARIA where needed, sufficient color contrast, and thorough testing, are the same techniques that produce more robust, more maintainable, and more usable software for all users.
The standard to aim for is not compliance. Compliance is a floor, not a ceiling. The standard is an interface that any person can use effectively, regardless of the device, assistive technology, or physical capability they bring to it.
Build for everyone. That is what good engineering looks like.