A comprehensive guide to building accessible websites that work for everyone - covering HTML, CSS, JavaScript, ARIA, and modern web development techniques for WCAG 2.1 Level AA compliance
Web accessibility ensures that websites, tools, and technologies are designed and developed so that people with disabilities can use them. More specifically, people can perceive, understand, navigate, interact with, and contribute to the web.
Accessibility encompasses all disabilities that affect access to the web:
Beyond disabilities, accessible websites benefit everyone: older adults with changing abilities, mobile users in bright sunlight, people with temporary impairments (broken arm, eye surgery), users on slow connections, and people in noisy or quiet environments where they can't use audio.
The Web Content Accessibility Guidelines (WCAG) 2.1 Level AA is the internationally recognized standard for web accessibility. It's referenced by laws worldwide including the ADA (US), EAA (EU), BFSG (Germany), and many others.
WCAG 2.1 is organized around four principles known as POUR:
WCAG has three conformance levels: A (lowest), AA (mid-range, legally required in most jurisdictions), and AAA (highest). This guide focuses on Level AA compliance.
Effective accessibility requires thinking about it from the beginning, not bolting it on at the end. Follow these principles:
Semantic HTML is the single most important aspect of web accessibility. Screen readers, keyboard navigation, and browser accessibility features all rely on proper HTML structure.
Use HTML5 landmark elements to define page regions. Screen reader users can jump between landmarks for quick navigation.
<!-- GOOD: Semantic landmarks -->
<header>
<nav>
<!-- Main navigation -->
</nav>
</header>
<main>
<article>
<!-- Primary content -->
</article>
<aside>
<!-- Sidebar/supplementary content -->
</aside>
</main>
<footer>
<!-- Footer content -->
</footer>
<!-- BAD: Generic divs -->
<div class="header">
<div class="nav"></div>
</div>
<div class="content"></div>
<div class="footer"></div>
<main> per page. You can have multiple <nav>, <aside>, and <section> elements, but label them with aria-label if there are multiple of the same type (e.g., <nav aria-label="Primary"> and <nav aria-label="Footer">).
Headings (<h1>-<h6>) create a document outline. Screen reader users navigate by headings, so proper hierarchy is crucial.
<!-- GOOD: Logical heading hierarchy -->
<h1>Page Title</h1>
<h2>Section 1</h2>
<h3>Subsection 1.1</h3>
<h3>Subsection 1.2</h3>
<h2>Section 2</h2>
<h3>Subsection 2.1</h3>
<!-- BAD: Skipped levels and styling-based choices -->
<h1>Page Title</h1>
<h4>Section (h4 because it looks better)</h4>
<h2>Another Section</h2>
Best practices:
<h1> per page (typically the page title)Use proper list markup for related items. Screen readers announce "List with X items" and allow users to skip through lists.
<!-- GOOD: Semantic lists --> <ul> <li>Unordered item 1</li> <li>Unordered item 2</li> </ul> <ol> <li>Step 1</li> <li>Step 2</li> </ol> <dl> <dt>Term</dt> <dd>Definition</dd> </dl> <!-- BAD: Fake lists with divs --> <div class="list"> <div class="list-item">• Item 1</div> <div class="list-item">• Item 2</div> </div>
One of the most common accessibility mistakes is confusing buttons and links. They are semantically different and convey different meaning to assistive technologies.
| Element | Purpose | Keyboard | Screen Reader |
|---|---|---|---|
<a> |
Navigation (goes somewhere) | Enter to activate | "Link" |
<button> |
Action (does something) | Enter or Space | "Button" |
<!-- GOOD: Proper usage --> <a href="/products">View Products</a> <button type="button" onclick="openModal()">Open Modal</button> <button type="submit">Submit Form</button> <!-- BAD: Misused elements --> <div class="button" onclick="navigate()">Go</div> <a href="#" onclick="doAction()">Click me</a> <button onclick="location.href='/page'">Navigate</button>
div or span as Interactive Elements
<div onclick="..."> and <span onclick="..."> are not keyboard accessible, have no semantic meaning, and require extensive ARIA to work with assistive technologies. Always use proper <button> or <a> elements. If you absolutely must use a div, add role="button", tabindex="0", and keyboard event handlers—but just use a <button> instead.
Forms are critical for user interaction and must be fully accessible. Proper form markup ensures screen reader users understand what to enter and receive feedback about errors.
Every form input must have an associated <label>. Labels make the clickable area larger and provide essential information to screen readers.
<!-- GOOD: Proper label association --> <label for="email">Email Address</label> <input type="email" id="email" name="email" required> <!-- ALSO GOOD: Implicit association --> <label> Password <input type="password" name="password" required> </label> <!-- BAD: No label --> <input type="text" placeholder="Enter name"> <!-- BAD: Placeholder is not a label --> <input type="email" placeholder="Email">
Use aria-describedby to associate instructions with inputs.
<label for="password">Password</label> <input type="password" id="password" aria-describedby="password-hint" required> <div id="password-hint"> Must be at least 8 characters with 1 number and 1 special character </div>
Provide clear, accessible error messages associated with the specific fields that have errors.
<label for="email">Email Address</label> <input type="email" id="email" aria-invalid="true" aria-describedby="email-error"> <div id="email-error" role="alert"> Please enter a valid email address </div>
Error message best practices:
aria-invalid="true" on fields with errorsrole="alert" so screen readers announce errors immediatelyMark required fields programmatically and visually.
<label for="name"> Full Name <span aria-label="required">*</span> </label> <input type="text" id="name" required aria-required="true"> <!-- OR include "required" in the label text --> <label for="email">Email Address (required)</label> <input type="email" id="email" required>
Use <fieldset> and <legend> to group related form elements, especially radio buttons and checkboxes.
<fieldset>
<legend>Choose your subscription plan</legend>
<label>
<input type="radio" name="plan" value="free">
Free
</label>
<label>
<input type="radio" name="plan" value="pro">
Pro
</label>
</fieldset>
Use tables for tabular data, never for layout. Proper table markup helps screen reader users understand relationships between data.
<table>
<caption>Sales Report Q1 2025</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Revenue</th>
<th scope="col">Profit</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">January</th>
<td>$50,000</td>
<td>$12,000</td>
</tr>
</tbody>
</table>
Table best practices:
<caption> or aria-label to describe the table<th> for headers with scope="col" or scope="row"<thead>, <tbody>, and <tfoot> to group rowsheaders attribute to associate data cells with multiple headers
Every <img> must have an alt attribute. Alt text should convey the purpose and content of the image, not just describe it.
For images that convey information, describe the content and meaning.
<!-- GOOD: Descriptive alt text --> <img src="chart.png" alt="Bar chart showing 50% increase in sales from 2024 to 2025"> <!-- BAD: Not descriptive enough --> <img src="chart.png" alt="Chart"> <img src="chart.png" alt="Bar chart">
For purely decorative images that don't add information, use empty alt text to hide them from screen readers.
<!-- GOOD: Empty alt for decorative image --> <img src="divider.png" alt=""> <!-- ALSO GOOD: CSS background image for decorative content --> <div style="background-image: url(decoration.png)"></div>
For images inside links or buttons, describe the action, not the image.
<!-- GOOD: Describes action --> <a href="/search"> <img src="search-icon.png" alt="Search"> </a> <button> <img src="close.png" alt="Close dialog"> </button> <!-- BAD: Describes image instead of action --> <a href="/search"> <img src="search-icon.png" alt="Magnifying glass icon"> </a>
For complex images like infographics, charts, or diagrams, provide both short alt text and a longer description.
<img
src="sales-data.png"
alt="Quarterly sales performance graph"
aria-describedby="graph-description">
<div id="graph-description">
<h3>Detailed Description</h3>
<p>The graph shows quarterly sales from Q1 2024 to Q4 2024...</p>
<ul>
<li>Q1: $120,000</li>
<li>Q2: $145,000</li>
...
</ul>
</div>
All pre-recorded video with audio must have synchronized captions. Captions benefit deaf users, people in sound-sensitive environments, non-native speakers, and users with audio processing difficulties.
<video controls> <source src="video.mp4" type="video/mp4"> <track kind="captions" src="captions-en.vtt" srclang="en" label="English" default> <track kind="captions" src="captions-de.vtt" srclang="de" label="Deutsch"> </video>
Caption best practices:
Video content should have audio descriptions narrating important visual information during pauses in dialogue.
<video controls> <source src="video.mp4" type="video/mp4"> <track kind="descriptions" src="descriptions.vtt" srclang="en" label="Audio Descriptions"> </video>
Provide text transcripts for audio-only content (podcasts, audio interviews) and as an additional alternative for video.
<audio controls> <source src="podcast.mp3" type="audio/mpeg"> Your browser does not support the audio element. </audio> <p><a href="transcript.html">Read transcript</a></p>
Ensure media players have accessible controls:
controls attribute) when possibleIf an icon is purely decorative (adjacent text already describes the action), hide it from screen readers.
<button>
<svg aria-hidden="true" focusable="false">
<!-- icon markup -->
</svg>
Save
</button>
If an icon stands alone without text, provide accessible text.
<button aria-label="Close">
<svg aria-hidden="true" focusable="false">
<!-- X icon -->
</svg>
</button>
<!-- OR use visually hidden text -->
<button>
<svg aria-hidden="true" focusable="false">
<!-- icon -->
</svg>
<span class="sr-only">Close</span>
</button>
For informative SVG images, use <title> and <desc> elements.
<svg role="img" aria-labelledby="chart-title chart-desc"> <title id="chart-title">Sales Growth Chart</title> <desc id="chart-desc">Bar chart showing 25% sales growth from 2024 to 2025</desc> <!-- SVG content --> </svg>
All functionality must be available via keyboard. Many users cannot use a mouse due to motor disabilities, and power users often prefer keyboard navigation.
Standard keyboard interactions:
Keyboard users must always know where focus is. Never remove focus outlines without providing an alternative indicator.
/* BAD: Removing focus outline entirely */
button:focus {
outline: none;
}
/* GOOD: Custom focus style */
button:focus {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* BETTER: Use :focus-visible for keyboard-only focus */
button:focus {
outline: none; /* Remove for mouse clicks */
}
button:focus-visible {
outline: 2px solid #2563eb; /* Show for keyboard */
outline-offset: 2px;
}
Tab order should follow visual order and logical flow. Avoid using tabindex values greater than 0.
<!-- GOOD: Natural DOM order determines tab order --> <button>First</button> <button>Second</button> <button>Third</button> <!-- BAD: Positive tabindex disrupts natural order --> <button tabindex="3">Third</button> <button tabindex="1">First</button> <button tabindex="2">Second</button> <!-- ACCEPTABLE: tabindex="-1" for programmatic focus --> <div tabindex="-1" id="error-summary"> Please fix the following errors... </div>
When a modal dialog opens, focus should move into it and remain trapped until closed.
// Focus trap implementation
function trapFocus(element) {
const focusableElements = element.querySelectorAll(
'a[href], button:not([disabled]), textarea, input, select'
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
element.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
}
} else {
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
}
});
firstFocusable.focus();
}
Provide a "skip to main content" link as the first focusable element on each page, allowing keyboard users to bypass repetitive navigation.
<!-- HTML -->
<a href="#main" class="skip-link">Skip to main content</a>
<nav>...</nav>
<main id="main">...</main>
/* CSS - Hidden until focused */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
ARIA (Accessible Rich Internet Applications) adds semantic meaning to HTML elements. However, it should be used carefully:
role="presentation" or aria-hidden="true" on focusable elements<button> is always better than <div role="button">. ARIA doesn't add behavior—it only changes how assistive technologies interpret elements. If you find yourself using extensive ARIA, reconsider your HTML structure.
Use these to provide accessible names when visual labels aren't sufficient.
<!-- aria-label: Direct label --> <button aria-label="Close dialog"> <svg>...</svg> </button> <!-- aria-labelledby: Reference another element --> <div role="dialog" aria-labelledby="dialog-title"> <h2 id="dialog-title">Confirm Delete</h2> ... </div> <!-- Multiple labels --> <button aria-labelledby="btn-label icon-label"> <span id="btn-label">Download</span> <svg aria-hidden="true">...</svg> <span id="icon-label" class="sr-only">PDF file</span> </button>
Provides additional description or instructions.
<input type="text" id="username" aria-describedby="username-hint username-error"> <div id="username-hint">Must be 3-20 characters</div> <div id="username-error" hidden>Username already taken</div>
Announce dynamic content changes to screen readers.
<!-- Polite: Announce when screen reader is idle --> <div aria-live="polite" aria-atomic="true"> Items in cart: 3 </div> <!-- Assertive: Announce immediately (use sparingly) --> <div role="alert" aria-live="assertive"> Error: Payment failed </div> <!-- Status: For status updates --> <div role="status" aria-live="polite"> Loading... </div>
<div class="accordion">
<h3>
<button
aria-expanded="false"
aria-controls="panel1"
id="accordion1">
Section 1
</button>
</h3>
<div
id="panel1"
role="region"
aria-labelledby="accordion1"
hidden>
Content...
</div>
</div>
<div class="tabs">
<div role="tablist" aria-label="Products">
<button
role="tab"
aria-selected="true"
aria-controls="panel-1"
id="tab-1">
Description
</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-2"
id="tab-2"
tabindex="-1">
Reviews
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
Description content...
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
Reviews content...
</div>
</div>
<div role="dialog" aria-labelledby="dialog-title" aria-describedby="dialog-desc" aria-modal="true"> <h2 id="dialog-title">Confirm Action</h2> <p id="dialog-desc">Are you sure you want to delete this item?</p> <button>Cancel</button> <button>Delete</button> </div>
Sufficient color contrast ensures text is readable for users with low vision or color blindness.
WCAG 2.1 Level AA contrast requirements:
/* GOOD: Sufficient contrast */
.text {
color: #333333; /* Dark gray */
background: #FFFFFF; /* White */
/* Contrast ratio: 12.6:1 ✓ */
}
.button {
color: #FFFFFF; /* White */
background: #0066CC; /* Blue */
/* Contrast ratio: 6.6:1 ✓ */
}
/* BAD: Insufficient contrast */
.subtle-text {
color: #AAAAAA; /* Light gray */
background: #FFFFFF; /* White */
/* Contrast ratio: 2.3:1 ✗ FAILS */
}
.link {
color: #90CAF9; /* Light blue */
background: #FFFFFF; /* White */
/* Contrast ratio: 2.0:1 ✗ FAILS */
}
Color should not be the only visual means of conveying information. Add text, patterns, icons, or other indicators.
<!-- BAD: Color only --> <span style="color: red;">Error</span> <span style="color: green;">Success</span> <!-- GOOD: Color + text/icon --> <span class="error"> <svg aria-hidden="true">⚠</svg> Error: Invalid input </span> <span class="success"> <svg aria-hidden="true">✓</svg> Success: Saved </span> <!-- Graph example --> <!-- BAD: Color-coded lines only --> <!-- GOOD: Color + patterns/labels --> <svg> <line stroke="blue" stroke-dasharray="5,5" /> <!-- Dashed --> <text>Category A</text> <line stroke="red" stroke-dasharray="10,5" /> <!-- Different dash --> <text>Category B</text> </svg>
Content must be readable and functional when text is resized up to 200% without assistive technology.
rem, em, %) instead of fixed pixels for font sizes<meta name="viewport" maximum-scale=1>/* GOOD: Relative units */
body {
font-size: 100%; /* 16px typically */
}
h1 {
font-size: 2.5rem; /* 40px */
}
p {
font-size: 1rem; /* 16px */
line-height: 1.5; /* 24px */
}
/* BAD: Fixed pixels */
body {
font-size: 16px;
}
h1 {
font-size: 40px;
}
prefers-reduced-motion/* Respect user motion preferences */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Touch target sizing */
button,
a {
min-width: 44px;
min-height: 44px;
padding: 12px;
}
Accessibility testing requires multiple approaches. No single tool catches all issues.
Unplug your mouse and test your site with keyboard only:
Common screen readers:
What to test: