WCAG 2.1 guidelines and implementation checklist
Provide text alternatives for non-text content
<img src="logo.png" alt="Company Logo">
Provide alternatives for time-based media
<video><track kind="captions" src="captions.vtt">
Create content that can be presented in different ways
Use semantic HTML: <main>, <nav>, <section>
Make it easier to see and hear content
color: #000; background: #fff; /* 4.5:1 contrast ratio */
Make all functionality available from keyboard
tabindex="0" onkeydown="handleKeyPress(event)"
Provide users enough time to read and use content
setTimeout(() => {}, 20000); // 20 second timeout
Do not design content that could cause seizures
animation: none; /* Avoid flashing content */
Provide ways to help users navigate
<nav><ul><li><a href="#main">Skip to main content</a></li></ul></nav>
Make text content readable and understandable
<html lang="en">
Make pages appear and operate in predictable ways
Consistent navigation placement
Help users avoid and correct mistakes
<label for="email">Email:</label><input type="email" id="email" required>
Maximize compatibility with current and future tools
Use valid HTML and proper ARIA attributes
Ensure compatibility with assistive technologies
role="button" aria-label="Close dialog"
H1-H6 in logical order
<h1>Main Title</h1><h2>Section</h2><h3>Subsection</h3>
Use appropriate HTML5 elements
<main>, <nav>, <section>, <article>, <aside>, <footer>
Associate labels with form controls
<label for="name">Name:</label><input id="name" type="text">
Use ul, ol, dl for lists
<ul><li>Item 1</li><li>Item 2</li></ul>
Provide accessible name
<button aria-label="Close dialog">×</button>
Provide additional description
<input aria-describedby="help-text">
Indicate expandable content
<button aria-expanded="false">Menu</button>
Hide decorative content
<div aria-hidden="true">Decoration</div>
Define element role
role="button", role="dialog", role="navigation"
Logical tab sequence
tabindex="0" for focusable elements
Support Enter, Space, Arrow keys
onkeydown="handleKeyPress(event)"
Clear focus styling
:focus { outline: 2px solid #007bff; }
Skip to main content
<a href="#main" class="skip-link">Skip to main content</a>
4.5:1 for normal text, 3:1 for large text
color: #000; background: #fff; /* 21:1 ratio */
Use additional indicators
Required fields marked with * and red border
Ensure content works in grayscale
Use patterns and icons, not just color
Proper modal implementation with ARIA
<!-- Modal Trigger -->
<button aria-haspopup="dialog" aria-expanded="false" onclick="openModal()">
Open Dialog
</button>
<!-- Modal -->
<div id="modal" role="dialog" aria-labelledby="modal-title" aria-describedby="modal-description" aria-hidden="true">
<div class="modal-content">
<h2 id="modal-title">Modal Title</h2>
<p id="modal-description">Modal description</p>
<!-- Close button -->
<button aria-label="Close dialog" onclick="closeModal()">×</button>
<!-- Modal content -->
<div class="modal-body">
<p>Modal content here...</p>
</div>
</div>
</div>
<script>
function openModal() {
const modal = document.getElementById('modal');
modal.setAttribute('aria-hidden', 'false');
modal.querySelector('button[aria-label="Close dialog"]').focus();
// Trap focus
document.addEventListener('keydown', trapFocus);
}
function closeModal() {
const modal = document.getElementById('modal');
modal.setAttribute('aria-hidden', 'true');
document.querySelector('button[aria-haspopup="dialog"]').focus();
// Remove focus trap
document.removeEventListener('keydown', trapFocus);
}
function trapFocus(e) {
if (e.key === 'Tab') {
const focusableElements = document.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
}
}
</script>
Form validation with proper error handling
<form novalidate>
<div class="form-group">
<label for="email">Email Address *</label>
<input
type="email"
id="email"
name="email"
required
aria-describedby="email-error email-help"
aria-invalid="false"
>
<div id="email-help" class="help-text">
Enter a valid email address
</div>
<div id="email-error" class="error-text" role="alert" aria-live="polite"></div>
</div>
<button type="submit">Submit</button>
</form>
<script>
const form = document.querySelector('form');
const emailInput = document.getElementById('email');
const emailError = document.getElementById('email-error');
emailInput.addEventListener('blur', validateEmail);
emailInput.addEventListener('input', clearError);
function validateEmail() {
const email = emailInput.value;
const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/;
if (!email) {
showError('Email is required');
} else if (!emailRegex.test(email)) {
showError('Please enter a valid email address');
} else {
clearError();
}
}
function showError(message) {
emailInput.setAttribute('aria-invalid', 'true');
emailError.textContent = message;
emailError.style.display = 'block';
}
function clearError() {
emailInput.setAttribute('aria-invalid', 'false');
emailError.textContent = '';
emailError.style.display = 'none';
}
</script>
Responsive navigation with proper ARIA
<nav role="navigation" aria-label="Main navigation">
<button
class="menu-toggle"
aria-expanded="false"
aria-controls="main-menu"
onclick="toggleMenu()"
>
<span class="sr-only">Toggle menu</span>
<span class="hamburger"></span>
</button>
<ul id="main-menu" class="nav-menu">
<li><a href="/" aria-current="page">Home</a></li>
<li>
<button
aria-expanded="false"
aria-haspopup="true"
onclick="toggleSubmenu(this)"
>
Products
<span class="sr-only">submenu</span>
</button>
<ul class="submenu">
<li><a href="/products/software">Software</a></li>
<li><a href="/products/hardware">Hardware</a></li>
</ul>
</li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.nav-menu {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.nav-menu li {
position: relative;
}
.nav-menu a,
.nav-menu button {
display: block;
padding: 1rem;
text-decoration: none;
color: inherit;
background: none;
border: none;
cursor: pointer;
}
.nav-menu a:focus,
.nav-menu button:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}
.submenu {
display: none;
position: absolute;
top: 100%;
left: 0;
background: white;
border: 1px solid #ccc;
list-style: none;
margin: 0;
padding: 0;
min-width: 200px;
}
.submenu[aria-expanded="true"] {
display: block;
}
</style>
<script>
function toggleMenu() {
const button = document.querySelector('.menu-toggle');
const menu = document.getElementById('main-menu');
const isExpanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', !isExpanded);
menu.classList.toggle('open');
}
function toggleSubmenu(button) {
const submenu = button.nextElementSibling;
const isExpanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', !isExpanded);
submenu.setAttribute('aria-expanded', !isExpanded);
}
</script>
Table with proper headers and captions
<table>
<caption>Monthly Sales Report</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Product A</th>
<th scope="col">Product B</th>
<th scope="col">Total</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">January</th>
<td>$1,000</td>
<td>$2,000</td>
<td>$3,000</td>
</tr>
<tr>
<th scope="row">February</th>
<td>$1,200</td>
<td>$2,300</td>
<td>$3,500</td>
</tr>
<tr>
<th scope="row">March</th>
<td>$1,500</td>
<td>$2,800</td>
<td>$4,300</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<td>$3,700</td>
<td>$7,100</td>
<td>$10,800</td>
</tr>
</tfoot>
</table>
<style>
table {
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
}
caption {
font-weight: bold;
margin-bottom: 0.5rem;
text-align: left;
}
th, td {
border: 1px solid #ddd;
padding: 0.5rem;
text-align: left;
}
th {
background-color: #f5f5f5;
font-weight: bold;
}
thead th {
background-color: #e9ecef;
}
tfoot th {
background-color: #f8f9fa;
font-weight: bold;
}
</style>
Image gallery with proper alt text and descriptions
<div class="gallery" role="region" aria-label="Photo gallery">
<div class="gallery-controls">
<button
class="prev-btn"
aria-label="Previous image"
onclick="previousImage()"
>
← Previous
</button>
<span class="image-counter" aria-live="polite">
Image 1 of 3
</span>
<button
class="next-btn"
aria-label="Next image"
onclick="nextImage()"
>
Next →
</button>
</div>
<div class="gallery-container">
<img
src="image1.jpg"
alt="A beautiful sunset over the mountains with orange and pink clouds"
id="current-image"
role="img"
aria-describedby="image-description"
>
<div id="image-description" class="image-description">
A stunning sunset view showing the mountains silhouetted against a colorful sky
</div>
</div>
<div class="gallery-thumbnails" role="tablist" aria-label="Gallery thumbnails">
<button
role="tab"
aria-selected="true"
aria-controls="image1"
onclick="showImage(0)"
class="thumbnail active"
>
<img src="thumb1.jpg" alt="Sunset thumbnail">
</button>
<button
role="tab"
aria-selected="false"
aria-controls="image2"
onclick="showImage(1)"
class="thumbnail"
>
<img src="thumb2.jpg" alt="Mountain thumbnail">
</button>
<button
role="tab"
aria-selected="false"
aria-controls="image3"
onclick="showImage(2)"
class="thumbnail"
>
<img src="thumb3.jpg" alt="Forest thumbnail">
</button>
</div>
</div>
<script>
const images = [
{
src: 'image1.jpg',
alt: 'A beautiful sunset over the mountains with orange and pink clouds',
description: 'A stunning sunset view showing the mountains silhouetted against a colorful sky'
},
{
src: 'image2.jpg',
alt: 'Snow-capped mountain peaks against a clear blue sky',
description: 'Majestic mountain peaks covered in snow, reaching into the clear blue sky'
},
{
src: 'image3.jpg',
alt: 'Dense green forest with sunlight filtering through the trees',
description: 'A peaceful forest scene with sunlight creating beautiful patterns through the trees'
}
];
let currentIndex = 0;
function showImage(index) {
currentIndex = index;
const image = images[index];
const imgElement = document.getElementById('current-image');
const description = document.getElementById('image-description');
const counter = document.querySelector('.image-counter');
imgElement.src = image.src;
imgElement.alt = image.alt;
description.textContent = image.description;
counter.textContent = `Image ${index + 1} of ${images.length}`;
// Update thumbnails
document.querySelectorAll('.thumbnail').forEach((thumb, i) => {
thumb.setAttribute('aria-selected', i === index);
thumb.classList.toggle('active', i === index);
});
}
function nextImage() {
const nextIndex = (currentIndex + 1) % images.length;
showImage(nextIndex);
}
function previousImage() {
const prevIndex = (currentIndex - 1 + images.length) % images.length;
showImage(prevIndex);
}
</script>
Test with screen readers like NVDA, JAWS, or VoiceOver
Use automated tools like axe-core, Lighthouse, or WAVE
Ensure keyboard navigation works for all interactive elements
Provide sufficient color contrast (4.5:1 for normal text)
Use semantic HTML and proper heading structure