Web Accessibility in Practice: Semantic HTML First, ARIA Done Right, Focus Management and Contrast
Key takeaways
Accessibility bugs are mismatches between what a page looks like and what the accessibility tree says. This guide works through semantic HTML, the ARIA mistakes that make things worse, focus management that releases correctly, measured contrast values, and a testing routine that goes beyond a clean axe report.
Understanding Web Accessibility Fundamentals
Web accessibility ensures that websites, applications, and digital tools are usable by people with disabilities. This includes users who are blind or have low vision, deaf or hard of hearing, have motor disabilities, cognitive differences, or use assistive technologies.
The business case for accessibility is compelling: you expand your potential user base, improve SEO, enhance overall user experience, reduce legal risk, and often discover that accessible design benefits all users. Many accessibility improvements also boost performance and code quality.
The foundation of web accessibility rests on four key principles: Perceivable (information must be presentable in ways users can perceive), Operable (interface components must be operable), Understandable (information and UI operation must be understandable), and Robust (content must be robust enough for various assistive technologies).
In practice, the principles matter less than one technical fact: assistive technology does not see your page the way you do. A screen reader reads the browser’s accessibility tree, a parallel structure derived from the DOM, in which each node has a role (“button”, “link”, “heading level 2”), a name (“Save changes”), and a state (“expanded”, “checked”). CSS styling, click handlers on divs, and visual layout do not appear in it. A <div class="btn" onclick="…"> looks like a button but appears in the tree as plain text: it cannot be focused with Tab, is not announced as a button, and does not respond to Enter or Space.
Almost every accessibility bug is a mismatch between what the page looks like and what the accessibility tree says. Chrome and Firefox DevTools both have an Accessibility panel that shows the tree for the selected element. Checking the computed role and name there is the fastest debugging step, and I would do it before reaching for any testing tool.
A note on how to read the markup examples below: several of them deliberately keep patterns that are very common in real codebases, such as redundant roles and aria-label in the wrong place, and the prose after each example explains what is wrong with them. Recognizing these patterns in existing code is most of the practical work of an accessibility review.
Semantic HTML: The Foundation of Accessibility
Proper Document Structure
Semantic HTML provides the structural foundation that assistive technologies rely on to understand and navigate content:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Accessible Page Title - Site Name</title>
</head>
<body>
<!-- Skip navigation for keyboard users -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<header role="banner">
<nav role="navigation" aria-label="Main navigation">
<ul>
<li><a href="/" aria-current="page">Home</a></li>
<li><a href="/about/">About</a></li>
<li><a href="/contact/">Contact</a></li>
</ul>
</nav>
</header>
<main id="main-content" role="main">
<h1>Page Heading</h1>
<section aria-labelledby="section-heading">
<h2 id="section-heading">Section Title</h2>
<p>Content goes here...</p>
</section>
<aside role="complementary" aria-label="Related links">
<h2>Related Articles</h2>
<!-- Related content -->
</aside>
</main>
<footer role="contentinfo">
<p>© 2026 Company Name. All rights reserved.</p>
</footer>
</body>
</html>
The role attributes in this example (role="banner", role="navigation", role="main", role="contentinfo") are redundant. <header> at the top level of the body, <nav>, <main>, and <footer> already have exactly these roles. Adding them was a workaround for old browsers and screen readers from around 2012, and modern validators flag them as unnecessary. They do no harm here, but they point to a habit worth breaking: the first rule of ARIA is to not use ARIA when a native HTML element already provides the semantics. Every ARIA attribute you add is a promise you have to keep correct by hand, and native elements keep it for you.
The parts of this example that really matter are lang="en" (without it, screen readers may read English text with the pronunciation rules of the user’s system language), a descriptive <title> (the first thing announced when the page loads), and the skip link (discussed below). The aria-label on each <nav> matters as soon as a page has more than one, because otherwise screen reader users see a list of landmarks that are all just called “navigation”.
Heading Hierarchy and Navigation
Proper heading structure creates a navigable outline for screen reader users:
<!-- Good: Logical heading hierarchy -->
<article>
<h1>Main Article Title</h1>
<section>
<h2>Introduction</h2>
<p>Article introduction...</p>
<h3>Key Points</h3>
<ul>
<li>Point 1</li>
<li>Point 2</li>
</ul>
</section>
<section>
<h2>Detailed Analysis</h2>
<h3>Method 1</h3>
<p>Analysis of method 1...</p>
<h4>Implementation Details</h4>
<p>Specific implementation...</p>
<h3>Method 2</h3>
<p>Analysis of method 2...</p>
</section>
<section>
<h2>Conclusion</h2>
<p>Final thoughts...</p>
</section>
</article>
<!-- Bad: Skipped heading levels -->
<article>
<h1>Main Title</h1>
<h4>Skipped to h4</h4> <!-- Bad: skips h2, h3 -->
<h2>Back to h2</h2> <!-- Confusing hierarchy -->
</article>
Headings matter because they are the most common way screen reader users move through a page. In WebAIM’s long-running screen reader user survey, navigating by headings is consistently the most popular way to find information on a long page. A user presses H to jump from heading to heading, or opens a list of all headings, the same way a sighted user scans bold text. If your “headings” are <div class="title"> elements, that list is empty and the page becomes one long block of text.
The most common cause of skipped levels is choosing a heading tag for its size. A designer wants a small bold label, a developer picks <h4> because it has the right default styling. Pick the level from the document outline, and set the size with CSS.
Form Accessibility Patterns
Forms are critical interaction points that must be accessible to all users:
<form novalidate>
<fieldset>
<legend>Personal Information</legend>
<!-- Text input with proper labeling -->
<div class="field-group">
<label for="full-name">
Full Name
<span class="required" aria-label="required">*</span>
</label>
<input
type="text"
id="full-name"
name="fullName"
required
aria-describedby="name-help name-error"
aria-invalid="false"
>
<div id="name-help" class="help-text">
Enter your first and last name
</div>
<div id="name-error" class="error-message" role="alert" aria-live="polite">
<!-- Error message appears here -->
</div>
</div>
<!-- Email with validation -->
<div class="field-group">
<label for="email">Email Address *</label>
<input
type="email"
id="email"
name="email"
required
autocomplete="email"
aria-describedby="email-help"
>
<div id="email-help" class="help-text">
We'll use this to send you updates
</div>
</div>
<!-- Radio button group -->
<fieldset>
<legend>Preferred Contact Method</legend>
<div class="radio-group">
<input type="radio" id="contact-email" name="contactMethod" value="email">
<label for="contact-email">Email</label>
</div>
<div class="radio-group">
<input type="radio" id="contact-phone" name="contactMethod" value="phone">
<label for="contact-phone">Phone</label>
</div>
<div class="radio-group">
<input type="radio" id="contact-none" name="contactMethod" value="none">
<label for="contact-none">No contact preferred</label>
</div>
</fieldset>
<!-- Checkbox with proper association -->
<div class="field-group">
<input
type="checkbox"
id="newsletter"
name="newsletter"
aria-describedby="newsletter-help"
>
<label for="newsletter">Subscribe to newsletter</label>
<div id="newsletter-help" class="help-text">
Monthly updates about new content and features
</div>
</div>
</fieldset>
<button type="submit" class="primary-button">
Submit Form
</button>
</form>
The <label for> + id pairing is the core of this example. It gives each input an accessible name, and it also makes the label text clickable, which increases the target area for everyone (try clicking “Subscribe to newsletter” instead of the tiny checkbox). The <fieldset>/<legend> around the radio buttons is what makes a screen reader say “Preferred Contact Method, group” before the options. Without it, a user hears “Email, radio button, 1 of 3” with no idea what the question was.
A few details in this form need correcting:
aria-label="required"on the<span>is unreliable.aria-labelis not supported on generic elements likespanwithout a role, and many screen readers ignore it there. Therequiredattribute on the input is already announced as “required”, so the asterisk should just be hidden witharia-hidden="true".- The error container combines
role="alert"(which implies an assertive, interrupting announcement) witharia-live="polite". Pick one. For inline field errors,politeplusaria-invalid="true"on the input is usually less disruptive. Also, a live region must already exist in the DOM, empty, before the message is inserted. A container that is created together with its text is often not announced at all. autocomplete="email"is worth adding to every personal-data field (name,tel,street-address). It lets browsers and password managers fill the field, which is a large help for users with motor or cognitive impairments, and it is a WCAG 2.1 requirement (1.3.5, Identify Input Purpose).
ARIA: Bridging the Semantic Gap
Essential ARIA Attributes
ARIA (Accessible Rich Internet Applications) attributes provide semantic meaning when HTML alone isn’t sufficient:
<!-- Button states and properties -->
<button
type="button"
aria-expanded="false"
aria-controls="mobile-menu"
aria-label="Toggle navigation menu"
class="menu-toggle"
>
<span class="hamburger-icon" aria-hidden="true"></span>
Menu
</button>
<nav id="mobile-menu" class="mobile-nav" aria-hidden="true">
<!-- Navigation content -->
</nav>
<!-- Loading states -->
<button
type="submit"
aria-describedby="loading-status"
disabled
>
Save Changes
</button>
<div id="loading-status" aria-live="polite" aria-atomic="true">
Saving your changes...
</div>
<!-- Progress indicators -->
<div
role="progressbar"
aria-valuenow="32"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Upload progress"
class="progress-bar"
>
<div class="progress-fill" style="width: 32%"></div>
</div>
<!-- Tab interface -->
<div role="tablist" aria-label="Account settings">
<button
role="tab"
aria-selected="true"
aria-controls="profile-panel"
id="profile-tab"
tabindex="0"
>
Profile
</button>
<button
role="tab"
aria-selected="false"
aria-controls="security-panel"
id="security-tab"
tabindex="-1"
>
Security
</button>
</div>
<div role="tabpanel" id="profile-panel" aria-labelledby="profile-tab">
<!-- Profile content -->
</div>
<div role="tabpanel" id="security-panel" aria-labelledby="security-tab" hidden>
<!-- Security content -->
</div>
Three of these examples show mistakes that often slip through code review:
The menu button’s aria-label overrides its visible text. Screen readers will say “Toggle navigation menu”, but the button visibly reads “Menu”. That breaks WCAG 2.5.3 (Label in Name): a speech-recognition user says “click Menu”, and nothing happens, because the button’s accessible name does not contain that word. When a button has visible text, let the text be the name, and drop the aria-label.
aria-hidden="true" on the closed navigation does not hide it from the keyboard. aria-hidden removes elements from the accessibility tree, but the links inside can still receive focus. Keyboard users then tab into invisible links, and screen reader users land on focused elements that are announced as nothing. Hide closed menus with the hidden attribute or display: none (which removes them from both the tree and the tab order), or with the inert attribute, now supported in all major browsers.
The disabled “Save Changes” button with a pre-filled live region. A disabled <button> is removed from the tab order, so its aria-describedby is rarely heard. And “Saving your changes…” is present in the live region from the start, so it will not be announced: live regions announce changes, not initial content. Put the text into the empty region at the moment saving starts. Also consider using aria-disabled="true" instead of disabled for buttons that are only temporarily unavailable, so they stay focusable and discoverable.
Complex Interactive Components
Building accessible custom components requires careful attention to keyboard behavior and screen reader support:
<!-- Custom dropdown/combobox -->
<div class="combobox-container">
<label for="country-select">Select Country</label>
<div class="combobox-wrapper">
<input
type="text"
id="country-select"
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-controls="country-listbox"
aria-describedby="country-help"
placeholder="Type to search countries..."
autocomplete="country-name"
>
<button
type="button"
aria-label="Show country options"
tabindex="-1"
class="combobox-button"
>
▼
</button>
</div>
<div id="country-help" class="help-text">
Type to filter countries, use arrow keys to navigate options
</div>
<ul
id="country-listbox"
role="listbox"
aria-label="Countries"
class="combobox-options"
hidden
>
<li role="option" aria-selected="false" data-value="us">United States</li>
<li role="option" aria-selected="false" data-value="ca">Canada</li>
<li role="option" aria-selected="false" data-value="mx">Mexico</li>
</ul>
</div>
<!-- Modal dialog -->
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
class="modal-dialog"
hidden
>
<div class="modal-content">
<header class="modal-header">
<h2 id="dialog-title">Confirm Action</h2>
<button
type="button"
aria-label="Close dialog"
class="modal-close"
>
✕
</button>
</header>
<div class="modal-body">
<p id="dialog-description">
Are you sure you want to delete this item? This action cannot be undone.
</p>
</div>
<footer class="modal-footer">
<button type="button" class="secondary-button">Cancel</button>
<button type="button" class="danger-button">Delete</button>
</footer>
</div>
</div>
A custom combobox is the component where “it works with my mouse” and “it works with a screen reader” differ the most. The markup above is only the starting point. The script must keep aria-expanded in sync, move a visual highlight with the arrow keys while focus stays in the input, point aria-activedescendant at the highlighted option’s id so the screen reader announces it, select on Enter, and close on Escape. Screen readers also differ in how they handle comboboxes, so test with at least two. Before building one, check whether a native <select> or an <input list> with a <datalist> is good enough. For most forms they are, and they are accessible without any code.
For the modal, the native <dialog> element opened with showModal() is now the better default. It gives you a focus trap, makes the rest of the page inert, closes on Escape, and returns focus to the element that opened it, which is everything the custom FocusTrap class below tries to do by hand.
Keyboard Navigation and Focus Management
Implementing Proper Tab Order
Logical tab order ensures keyboard users can navigate efficiently:
class AccessibleTabInterface {
constructor(tablistElement) {
this.tablist = tablistElement;
this.tabs = [...this.tablist.querySelectorAll('[role="tab"]')];
this.panels = [...document.querySelectorAll('[role="tabpanel"]')];
this.currentTab = 0;
this.init();
}
init() {
// Set initial states
this.tabs.forEach((tab, index) => {
tab.addEventListener('click', () => this.selectTab(index));
tab.addEventListener('keydown', (e) => this.handleKeydown(e, index));
// Set initial tab index
tab.setAttribute('tabindex', index === 0 ? '0' : '-1');
});
// Activate first tab without moving focus on page load
this.selectTab(0, { focus: false });
}
selectTab(index, { focus = true } = {}) {
// Deactivate all tabs and panels
this.tabs.forEach(tab => {
tab.setAttribute('aria-selected', 'false');
tab.setAttribute('tabindex', '-1');
});
this.panels.forEach(panel => {
panel.hidden = true;
});
// Activate selected tab and panel
const selectedTab = this.tabs[index];
const selectedPanel = this.panels[index];
selectedTab.setAttribute('aria-selected', 'true');
selectedTab.setAttribute('tabindex', '0');
if (focus) selectedTab.focus();
selectedPanel.hidden = false;
this.currentTab = index;
}
handleKeydown(event, index) {
const { key } = event;
switch (key) {
case 'ArrowRight':
event.preventDefault();
this.selectTab((index + 1) % this.tabs.length);
break;
case 'ArrowLeft':
event.preventDefault();
this.selectTab((index - 1 + this.tabs.length) % this.tabs.length);
break;
case 'Home':
event.preventDefault();
this.selectTab(0);
break;
case 'End':
event.preventDefault();
this.selectTab(this.tabs.length - 1);
break;
case 'Enter':
case ' ':
event.preventDefault();
this.selectTab(index);
break;
}
}
}
// Focus trap for modal dialogs
class FocusTrap {
constructor(element) {
this.element = element;
this.previousFocus = document.activeElement;
// Bind once so the same function reference can be removed later
this.handleKeydown = this.handleKeydown.bind(this);
}
get focusableElements() {
// Re-query on every use: dialog content can change while it is open
return this.getFocusableElements();
}
getFocusableElements() {
const selector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]'
].join(',');
return [...this.element.querySelectorAll(selector)]
.filter(el => !el.closest('[aria-hidden="true"], [hidden], [inert]'));
}
activate() {
this.element.addEventListener('keydown', this.handleKeydown);
// Focus first element or the element itself (needs tabindex="-1")
const [first] = this.focusableElements;
if (first) {
first.focus();
} else {
this.element.focus();
}
}
deactivate() {
this.element.removeEventListener('keydown', this.handleKeydown);
// Return focus to previously focused element
if (this.previousFocus && this.previousFocus.focus) {
this.previousFocus.focus();
}
}
handleKeydown(event) {
if (event.key !== 'Tab') return;
const items = this.focusableElements;
if (items.length === 0) {
event.preventDefault();
return;
}
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey) {
// Shift + Tab (backwards)
if (document.activeElement === first) {
event.preventDefault();
last.focus();
}
} else {
// Tab (forwards)
if (document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
}
The tab interface implements the roving tabindex pattern: only the selected tab has tabindex="0", so pressing Tab moves from the tab list straight into the panel instead of stopping on every tab, and the arrow keys move between tabs. This follows the WAI-ARIA Authoring Practices tab pattern, and it matches how native controls like radio groups behave. Note the focus: false option on the first selectTab call. Without it, the component moves keyboard focus to itself as soon as the page loads, which pulls screen reader users away from the top of the page before they have read anything. It is an easy bug to miss, because sighted mouse users never notice where focus is.
The FocusTrap class contains two fixes that are worth understanding, because the broken versions are extremely common:
this.handleKeydown.bind(this)creates a new function every time it is called. A version that callsbindin bothaddEventListenerandremoveEventListenerpasses two different functions, so the removal silently does nothing and the trap stays active after the dialog closes. Binding once in the constructor and reusing that reference fixes it.- The list of focusable elements is queried on each Tab press instead of once at construction. Dialogs often change while open (a validation error adds a link, a step reveals a new button), and a list captured at the start sends focus to elements that no longer exist, or skips new ones.
In my experience, the focus trap that never releases is the most frequent accessibility bug in hand-written modals, and it is invisible in testing with a mouse. It only shows up when a keyboard user closes the dialog and finds that Tab still cycles through buttons that are no longer on screen. This is why I now reach for the native <dialog> element first, and use a hand-written trap only when a design requirement truly rules it out.
Skip Links and Landmark Navigation
Provide efficient navigation shortcuts for keyboard and screen reader users:
<!-- Skip links at the top of the page -->
<div class="skip-links">
<a href="#main-content" class="skip-link">Skip to main content</a>
<a href="#navigation" class="skip-link">Skip to navigation</a>
<a href="#footer" class="skip-link">Skip to footer</a>
</div>
<style>
.skip-links {
position: absolute;
top: 0;
left: 0;
z-index: 1000;
}
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
border-radius: 0 0 4px 4px;
font-weight: bold;
}
.skip-link:focus {
top: 0;
}
</style>
<!-- Proper landmark structure -->
<header role="banner">
<nav id="navigation" role="navigation" aria-label="Main navigation">
<!-- Navigation content -->
</nav>
</header>
<main id="main-content" role="main">
<h1>Page Title</h1>
<nav role="navigation" aria-label="Breadcrumb">
<ol class="breadcrumb">
<li><a href="/">Home</a></li>
<li><a href="/category/">Category</a></li>
<li aria-current="page">Current Page</li>
</ol>
</nav>
<article role="article">
<!-- Main content -->
</article>
<aside role="complementary" aria-label="Related information">
<!-- Sidebar content -->
</aside>
</main>
<footer id="footer" role="contentinfo">
<!-- Footer content -->
</footer>
A skip link exists for keyboard users who would otherwise have to Tab through the entire header and navigation on every page. It is hidden off-screen until it receives focus, then appears at the top. Hiding it with display: none would defeat the point, because an element that is not rendered cannot be focused. One detail that often breaks skip links in single-page applications: if the router intercepts the #main-content link, focus may not move. Test it by pressing Tab once on page load, then Enter, then Tab again, and check that the next focused element is inside the main content. Three skip links are usually more than users need; one link to the main content is the common convention.
Color, Contrast, and Visual Design
Meeting WCAG Color Contrast Requirements
Ensure sufficient color contrast for users with visual impairments:
/* WCAG AA Requirements:
- Normal text: 4.5:1 contrast ratio
- Large text (18pt+/14pt+ bold): 3:1 contrast ratio
- AAA: 7:1 for normal, 4.5:1 for large text
*/
:root {
/* High contrast color palette */
--text-primary: #1a1a1a; /* 17.40:1 on white */
--text-secondary: #404040; /* 10.37:1 on white */
--text-muted: #666666; /* 5.74:1 on white (#767676 is the lightest gray at 4.5:1) */
--background: #ffffff;
--surface: #f8f9fa;
--border: #e5e7eb; /* 1.24:1 - decorative only */
/* Interactive colors */
--primary: #0066cc; /* 5.57:1 on white */
--primary-hover: #0052a3; /* 7.68:1 on white */
--success: #198754; /* 4.53:1 on white - barely AA */
--warning: #fd7e14; /* 2.57:1 - fails as text, use dark text on it */
--error: #dc3545; /* 4.53:1 on white - barely AA */
/* Dark theme overrides */
--dark-text-primary: #f8f9fa; /* 16.51:1 on #1a1a1a */
--dark-text-secondary: #e9ecef; /* 14.68:1 on #1a1a1a */
--dark-background: #1a1a1a;
--dark-surface: #2d3748;
}
/* Ensure interactive elements meet contrast requirements */
.button {
background-color: var(--primary);
color: white; /* 5.57:1 ratio */
border: 2px solid var(--primary);
}
.button:hover,
.button:focus {
background-color: var(--primary-hover);
border-color: var(--primary-hover);
/* Maintain or improve contrast on interaction */
}
.button.secondary {
background-color: transparent;
color: var(--primary); /* 5.57:1 on white background */
border: 2px solid var(--primary);
}
/* Error states need high contrast */
.input-error {
border-color: var(--error); /* 4.53:1 (non-text needs 3:1) */
}
.error-message {
color: var(--error); /* 4.53:1 on white */
font-weight: 600; /* Bold for better readability */
}
/* Warning states - use dark text for better contrast */
.warning-banner {
background-color: #fff3cd; /* Light yellow background */
color: #856404; /* 4.96:1 on #fff3cd */
border: 1px solid #ffeaa7;
}
/* Link colors that meet contrast requirements */
a {
color: var(--primary); /* 5.57:1 */
}
a:hover,
a:focus {
color: var(--primary-hover); /* 7.68:1 - improved contrast */
text-decoration: underline;
}
/* Visited links should remain accessible */
a:visited {
color: #6f42c1; /* 6.51:1 ratio */
}
/* Dark theme adaptations */
@media (prefers-color-scheme: dark) {
:root {
--text-primary: var(--dark-text-primary);
--text-secondary: var(--dark-text-secondary);
--background: var(--dark-background);
--surface: var(--dark-surface);
}
.button {
/* Adjust colors to maintain contrast in dark mode */
background-color: #4dabf7; /* Lighter blue for dark bg */
color: #1a1a1a; /* 7.03:1 on #4dabf7 */
}
}
Do not trust contrast numbers in comments or design docs, including the ones above, without measuring. Values passed around from memory are often wrong, and small differences decide pass or fail. #dc3545, a very common error red, measures only 4.53:1 on white, just above the AA threshold, so a slightly lighter variant or a light gray background makes it fail. #fd7e14, a common warning orange, fails badly as a text color. The only reliable source is a tool that computes the ratio from the actual colors: the contrast picker in browser DevTools, axe, or WebAIM’s contrast checker. Run it on the rendered colors, including text on top of images, gradients, and semi-transparent overlays, which is where contrast failures usually hide.
Two rules are easy to forget. First, WCAG 1.4.11 requires 3:1 for non-text UI: input borders, focus indicators, and icon-only buttons. The #e5e7eb border above (1.24:1) is fine as a decorative divider, but not as the only visible edge of a text field. Second, color may never be the only signal (WCAG 1.4.1). An error state shown only with a red border fails for the roughly 1 in 12 men with some form of color vision deficiency. Add an icon or text.
Responsive Design for Accessibility
Design that works across devices and zoom levels:
/* Responsive typography that scales well */
html {
font-size: 100%; /* Respect the user's browser font-size setting (16px by default) */
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
line-height: 1.6; /* Minimum 1.4 for readability */
font-size: 1rem;
}
/* Scalable spacing using relative units */
.content {
padding: clamp(1rem, 4vw, 2rem);
max-width: 65ch; /* Optimal reading line length */
}
/* Touch-friendly interactive elements */
button,
.clickable {
min-height: 44px; /* Minimum touch target size */
min-width: 44px;
padding: 0.75rem 1rem;
}
/* Ensure content works at 320px minimum width */
@media (max-width: 320px) {
.responsive-table {
font-size: 0.875rem;
}
.button {
width: 100%;
margin-bottom: 0.5rem;
}
}
/* Support for 200% zoom (WCAG requirement) */
@media (max-width: 640px) {
.two-column {
display: block; /* Stack columns at high zoom */
}
.horizontal-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
/* Reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.card {
border: 2px solid;
}
.button {
border: 2px solid currentColor;
font-weight: bold;
}
}
There is no prefers-font-size media query in any browser. Users who need larger text set it in their browser or operating system, and the way to honor that is to not override it: set the root font size as a percentage (or not at all), and size text and spacing in rem/em. Setting html { font-size: 16px } in pixels ignores a user’s browser setting of, say, 24px, and this is one of the most common reasons text refuses to grow on otherwise well-built sites.
The 200% zoom requirement (WCAG 1.4.4) and the reflow requirement (1.4.10: content usable at a 320 CSS-pixel viewport without horizontal scrolling) are connected. At 400% zoom, a 1280px-wide desktop window is effectively 320px wide, so a properly responsive layout meets both requirements for free. The quickest check is to zoom the browser to 400% and try to use the page. Sticky headers that take up half the screen at that zoom level are a very common failure.
prefers-reduced-motion matters more than it looks. For users with vestibular disorders, parallax scrolling and large sliding animations can cause real nausea and dizziness. The blanket reset above is a reasonable safety net, but a better design treats motion as an enhancement that is added only when prefers-reduced-motion: no-preference matches.
Testing and Validation Strategies
Automated Testing Integration
Integrate accessibility testing into your development workflow:
// Using axe-core for automated accessibility testing
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Accessibility Tests', () => {
test('homepage should be accessible', async () => {
const { container } = render(<HomePage />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('form validation should be accessible', async () => {
const { container } = render(<ContactForm />);
// Trigger validation errors
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
// Test that error messages are properly associated
const emailInput = screen.getByLabelText(/email/i);
expect(emailInput).toHaveAttribute('aria-invalid', 'true');
expect(emailInput).toHaveAccessibleDescription();
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// Playwright accessibility testing
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('accessibility scan', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
// Custom accessibility test helpers
class A11yTestHelpers {
static async testKeyboardNavigation(page) {
// Test tab order
const focusableElements = await page.$$eval(
'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])',
elements => elements.map(el => ({
tag: el.tagName,
text: el.textContent?.trim() || el.getAttribute('aria-label'),
tabIndex: el.tabIndex
}))
);
// Simulate tab navigation
for (let i = 0; i < focusableElements.length; i++) {
await page.keyboard.press('Tab');
const focused = await page.evaluate(() => ({
tag: document.activeElement?.tagName,
text: document.activeElement?.textContent?.trim() ||
document.activeElement?.getAttribute('aria-label')
}));
expect(focused.tag).toBe(focusableElements[i].tag);
}
}
// Call before the action, then read window.__liveUpdates after it
static async recordLiveRegionUpdates(page) {
await page.evaluate(() => {
window.__liveUpdates = [];
document.querySelectorAll('[aria-live], [role="alert"], [role="status"]')
.forEach(region => {
new MutationObserver(() => {
window.__liveUpdates.push(region.textContent.trim());
}).observe(region, { childList: true, characterData: true, subtree: true });
});
});
}
static async getLiveRegionUpdates(page) {
return page.evaluate(() => window.__liveUpdates);
}
static validateHeadingStructure(container) {
const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
let currentLevel = 0;
headings.forEach(heading => {
const level = parseInt(heading.tagName.charAt(1));
if (currentLevel === 0) {
expect(level).toBe(1); // First heading should be h1
} else {
expect(level).toBeLessThanOrEqual(currentLevel + 1); // Don't skip levels
}
currentLevel = level;
});
}
}
The live-region helper is split into two functions for a reason worth knowing. Code passed to page.evaluate runs inside the browser, not in your Node test process. An announcements array declared in the test and pushed to from inside evaluate does not work: the browser code cannot see that variable at all. State has to stay in the page (here, on window) and be read back with a second evaluate. Also keep in mind what this helper proves: it checks that the DOM of a live region changed, not that a screen reader actually spoke it. Timing, aria-atomic, and region setup decide the latter, and differ between screen readers.
The tab-order helper has a similar limitation. It assumes DOM order equals tab order, which breaks with positive tabindex values or elements hidden with CSS. For keyboard testing, asserting that specific, important elements receive focus in sequence (skip link → search → first nav link) is more robust than comparing against the whole DOM.
Automated scans are worth running on every pull request because they are cheap and catch regressions, such as a missing label or a contrast change. But treat a clean axe report as the minimum, not as proof of accessibility. axe cannot tell whether an alt text is meaningful, whether focus goes to the right place after an action, or whether a custom widget can be operated at all. expect(results.violations).toEqual([]) on a page with a completely unusable custom dropdown passes easily.
Manual Testing: A Short Routine That Finds Most Problems
A full manual audit takes time, but a short routine repeated on every new component finds most of the serious problems. I use roughly this order, from cheapest to most expensive:
- Unplug the mouse. Use only Tab, Shift+Tab, Enter, Space, the arrow keys, and Escape. Can you reach and operate everything? Is the focus indicator visible on every element, including over dark backgrounds? After closing a dialog or deleting an item, where does focus go? “Nowhere” (focus returns to the top of the document) is a bug.
- Zoom to 400% and check that nothing is cut off or overlapping, and that no horizontal scrolling is needed for normal text.
- Turn on a screen reader and use its navigation keys the way real users do, not just Tab. With NVDA on Windows (free) in browse mode:
Hjumps between headings,Dbetween landmarks,Fbetween form fields,Bbetween buttons, andInsert+F7lists all links and headings. With VoiceOver on macOS,VO+Uopens the rotor with the same lists. Check that each control is announced with a sensible name, role, and state, and that dynamic changes (errors, “item added”, loading) are announced.
Screen readers do not all behave the same way, and browser pairing matters: NVDA is usually tested with Chrome or Firefox, JAWS with Chrome, and VoiceOver with Safari. If you can only test one combination, NVDA with Chrome covers a large share of real desktop users. For mobile, TalkBack on Android and VoiceOver on iOS use swipe gestures instead of keys, and they regularly reveal touch-target and reading-order problems that desktop testing misses.
The first time you do step 3, expect it to feel slow and confusing. That is normal, and it is also useful information: it shows how much work your interface asks of people who use it this way every day. The most valuable fixes I have seen come from these sessions were not exotic ARIA, but ordinary things: buttons announced only as “button” because they contained just an icon, and forms where the error appeared on screen but was never announced.
Web accessibility isn’t just about compliance—it’s about creating inclusive experiences that work for everyone. By implementing these patterns systematically, testing regularly, and prioritizing accessibility from the start of your projects, you’ll build better products that serve a wider audience and often perform better overall.
The key is making accessibility a natural part of your development process rather than an afterthought. Start with semantic HTML, enhance with ARIA when needed, test early and often, and remember that good accessibility benefits all users, not just those with disabilities.
Related Articles
- HTML and CSS Basics
- Playwright E2E Testing: Auto-Waiting Locators, Route Mocking, Auth State and Sharded CI
- Finding and Fixing Slow React Renders