Essential accessibility patterns and techniques for building inclusive web applications.
Web Accessibility: Essential Patterns for Inclusive Design
Web accessibility ensures your applications work for everyone, including the 1.3 billion people worldwide with disabilities. This guide covers essential patterns for building truly accessible web experiences.
Table of Contents
- Semantic HTML Foundation
- Form Accessibility
- ARIA Implementation
- Keyboard Navigation
- Visual Design
- Dynamic Content
- Testing & Validation
Semantic HTML Foundation
Document Structure
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Page Title - Site Name</title>
</head>
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header>
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
</header>
<main id="main-content">
<h1>Page Heading</h1>
<section aria-labelledby="section-heading">
<h2 id="section-heading">Section Title</h2>
<!-- Content -->
</section>
</main>
<footer>
<!-- Footer content -->
</footer>
</body>
</html>
Heading Hierarchy
<!-- Proper heading structure -->
<h1>Main Page Title</h1>
<h2>Major Section</h2>
<h3>Subsection</h3>
<h3>Another Subsection</h3>
<h2>Another Major Section</h2>
<!-- Multiple articles -->
<article>
<h1>Article Title</h1>
<h2>Article Section</h2>
</article>
Image Accessibility
<!-- Informative images -->
<img src="sales-chart.png" alt="Sales increased 40% from Q1 to Q2 2024" />
<!-- Functional images -->
<button>
<img src="search-icon.svg" alt="Search" />
</button>
<!-- Decorative images -->
<img src="decoration.jpg" alt="" role="presentation" />
<!-- Complex images -->
<figure>
<img src="complex-chart.png" alt="Quarterly revenue breakdown" />
<figcaption>Q1: $2.1M, Q2: $2.8M, Q3: $3.2M, Q4: $2.9M</figcaption>
</figure>
Form Accessibility
Basic Form Structure
<form novalidate>
<fieldset>
<legend>Personal Information</legend>
<div class="form-group">
<label for="firstName">First Name</label>
<input
type="text"
id="firstName"
name="firstName"
required
aria-describedby="firstName-help"
autocomplete="given-name"
/>
<div id="firstName-help" class="help-text">
Enter your legal first name
</div>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
required
aria-describedby="email-help email-error"
autocomplete="email"
/>
<div id="email-help" class="help-text">
We'll use this to send you updates
</div>
<div id="email-error" class="error-message" role="alert" hidden>
Please enter a valid email address
</div>
</div>
</fieldset>
<button type="submit">Submit Form</button>
</form>
Form Validation
class AccessibleFormValidator {
constructor(form) {
this.form = form;
this.fields = form.querySelectorAll("input, select, textarea");
this.init();
}
init() {
this.form.addEventListener("submit", (e) => this.handleSubmit(e));
this.fields.forEach((field) => {
field.addEventListener("blur", () => this.validateField(field));
field.addEventListener("input", () => this.clearErrors(field));
});
}
validateField(field) {
const value = field.value.trim();
const isRequired = field.hasAttribute("required");
let errorMessage = "";
if (isRequired && !value) {
errorMessage = `${this.getFieldLabel(field)} is required`;
} else if (field.type === "email" && value && !this.isValidEmail(value)) {
errorMessage = "Please enter a valid email address";
}
this.setFieldError(field, errorMessage);
return !errorMessage;
}
setFieldError(field, message) {
const errorElement = document.getElementById(`${field.id}-error`);
if (message) {
field.setAttribute("aria-invalid", "true");
if (errorElement) {
errorElement.textContent = message;
errorElement.hidden = false;
}
} else {
field.removeAttribute("aria-invalid");
if (errorElement) {
errorElement.hidden = true;
}
}
}
handleSubmit(e) {
e.preventDefault();
const isValid = Array.from(this.fields).every((field) =>
this.validateField(field),
);
if (isValid) {
// Submit form
this.form.submit();
} else {
// Focus first invalid field
const firstInvalid = this.form.querySelector("[aria-invalid='true']");
if (firstInvalid) {
firstInvalid.focus();
}
}
}
}
Custom Select Component
class AccessibleSelect {
constructor(element) {
this.select = element;
this.button = element.querySelector("[role='combobox']");
this.listbox = element.querySelector("[role='listbox']");
this.options = element.querySelectorAll("[role='option']");
this.selectedIndex = 0;
this.isOpen = false;
this.init();
}
init() {
this.button.addEventListener("click", () => this.toggle());
this.button.addEventListener("keydown", (e) => this.handleButtonKeydown(e));
this.options.forEach((option, index) => {
option.addEventListener("click", () => this.selectOption(index));
});
}
toggle() {
this.isOpen = !this.isOpen;
this.button.setAttribute("aria-expanded", this.isOpen);
this.listbox.hidden = !this.isOpen;
if (this.isOpen) {
this.options[this.selectedIndex].focus();
}
}
selectOption(index) {
this.selectedIndex = index;
const selectedOption = this.options[index];
this.button.textContent = selectedOption.textContent;
this.button.setAttribute("aria-activedescendant", selectedOption.id);
this.options.forEach((option, i) => {
option.setAttribute("aria-selected", i === index);
});
this.toggle();
this.button.focus();
}
handleButtonKeydown(e) {
switch (e.key) {
case "Enter":
case " ":
e.preventDefault();
this.toggle();
break;
case "ArrowDown":
e.preventDefault();
if (!this.isOpen) {
this.toggle();
} else {
this.moveSelection(1);
}
break;
case "ArrowUp":
e.preventDefault();
if (!this.isOpen) {
this.toggle();
} else {
this.moveSelection(-1);
}
break;
case "Escape":
if (this.isOpen) {
this.toggle();
}
break;
}
}
}
ARIA Implementation
Live Regions
<!-- Status messages -->
<div aria-live="polite" aria-atomic="true" class="sr-only" id="status">
<!-- Dynamic status messages appear here -->
</div>
<!-- Urgent alerts -->
<div aria-live="assertive" aria-atomic="true" class="sr-only" id="alerts">
<!-- Critical alerts appear here -->
</div>
<!-- Usage in JavaScript -->
<script>
function announceStatus(message) {
const status = document.getElementById("status");
status.textContent = message;
}
function announceAlert(message) {
const alerts = document.getElementById("alerts");
alerts.textContent = message;
}
// Examples
announceStatus("Form saved successfully");
announceAlert("Connection lost. Please check your internet connection.");
</script>
Modal Dialog
<div
class="modal-overlay"
id="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
hidden
>
<div class="modal-content">
<header class="modal-header">
<h2 id="modal-title">Confirm Action</h2>
<button class="modal-close" aria-label="Close dialog">×</button>
</header>
<div class="modal-body">
<p>Are you sure you want to delete this item?</p>
</div>
<footer class="modal-footer">
<button class="btn-secondary">Cancel</button>
<button class="btn-danger">Delete</button>
</footer>
</div>
</div>
class AccessibleModal {
constructor(modalElement) {
this.modal = modalElement;
this.closeButton = modalElement.querySelector(".modal-close");
this.focusableElements = modalElement.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
this.firstFocusable = this.focusableElements[0];
this.lastFocusable =
this.focusableElements[this.focusableElements.length - 1];
this.previouslyFocused = null;
this.init();
}
init() {
this.closeButton.addEventListener("click", () => this.close());
this.modal.addEventListener("keydown", (e) => this.handleKeydown(e));
}
open() {
this.previouslyFocused = document.activeElement;
this.modal.hidden = false;
this.firstFocusable.focus();
document.body.style.overflow = "hidden";
}
close() {
this.modal.hidden = true;
document.body.style.overflow = "";
if (this.previouslyFocused) {
this.previouslyFocused.focus();
}
}
handleKeydown(e) {
if (e.key === "Escape") {
this.close();
return;
}
if (e.key === "Tab") {
if (e.shiftKey) {
if (document.activeElement === this.firstFocusable) {
e.preventDefault();
this.lastFocusable.focus();
}
} else {
if (document.activeElement === this.lastFocusable) {
e.preventDefault();
this.firstFocusable.focus();
}
}
}
}
}
Accordion Component
<div class="accordion">
<div class="accordion-item">
<h3>
<button
class="accordion-trigger"
aria-expanded="false"
aria-controls="panel-1"
id="trigger-1"
>
Section 1 Title
</button>
</h3>
<div
class="accordion-panel"
id="panel-1"
aria-labelledby="trigger-1"
hidden
>
<p>Section 1 content goes here.</p>
</div>
</div>
</div>
class AccessibleAccordion {
constructor(accordion) {
this.accordion = accordion;
this.triggers = accordion.querySelectorAll(".accordion-trigger");
this.init();
}
init() {
this.triggers.forEach((trigger, index) => {
trigger.addEventListener("click", () => this.toggle(index));
trigger.addEventListener("keydown", (e) => this.handleKeydown(e, index));
});
}
toggle(index) {
const trigger = this.triggers[index];
const panel = document.getElementById(
trigger.getAttribute("aria-controls"),
);
const isExpanded = trigger.getAttribute("aria-expanded") === "true";
trigger.setAttribute("aria-expanded", !isExpanded);
panel.hidden = isExpanded;
}
handleKeydown(e, index) {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
this.focusNext(index);
break;
case "ArrowUp":
e.preventDefault();
this.focusPrevious(index);
break;
case "Home":
e.preventDefault();
this.triggers[0].focus();
break;
case "End":
e.preventDefault();
this.triggers[this.triggers.length - 1].focus();
break;
}
}
focusNext(currentIndex) {
const nextIndex = (currentIndex + 1) % this.triggers.length;
this.triggers[nextIndex].focus();
}
focusPrevious(currentIndex) {
const prevIndex =
currentIndex === 0 ? this.triggers.length - 1 : currentIndex - 1;
this.triggers[prevIndex].focus();
}
}
Keyboard Navigation
Focus Management
/* Visible focus indicators */
:focus {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* Skip link styles */
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
z-index: 1000;
}
.skip-link:focus {
top: 6px;
}
/* Screen reader only content */
.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;
}
Roving Tabindex
class RovingTabindex {
constructor(container) {
this.container = container;
this.items = container.querySelectorAll(
"[role='tab'], [role='menuitem'], [role='option']",
);
this.currentIndex = 0;
this.init();
}
init() {
this.items.forEach((item, index) => {
item.tabIndex = index === 0 ? 0 : -1;
item.addEventListener("keydown", (e) => this.handleKeydown(e, index));
item.addEventListener("focus", () => this.setCurrentIndex(index));
});
}
handleKeydown(e, index) {
let newIndex = index;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
newIndex = (index + 1) % this.items.length;
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
newIndex = index === 0 ? this.items.length - 1 : index - 1;
break;
case "Home":
e.preventDefault();
newIndex = 0;
break;
case "End":
e.preventDefault();
newIndex = this.items.length - 1;
break;
default:
return;
}
this.focusItem(newIndex);
}
focusItem(index) {
this.items[this.currentIndex].tabIndex = -1;
this.items[index].tabIndex = 0;
this.items[index].focus();
this.currentIndex = index;
}
setCurrentIndex(index) {
this.currentIndex = index;
}
}
Visual Design
Color and Contrast
/* WCAG AA compliant color combinations */
:root {
--primary-color: #005fcc; /* 4.5:1 contrast ratio on white */
--secondary-color: #6c757d;
--success-color: #198754;
--warning-color: #fd7e14;
--error-color: #dc3545;
/* Text colors */
--text-primary: #212529; /* 16.75:1 contrast ratio on white */
--text-secondary: #6c757d; /* 4.5:1 contrast ratio on white */
}
/* Don't rely solely on color */
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.status-indicator::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-success {
color: var(--success-color);
}
.status-success::before {
background-color: var(--success-color);
}
.status-error {
color: var(--error-color);
}
.status-error::before {
background-color: var(--error-color);
}
Responsive Typography
/* Scalable text that respects user preferences */
html {
font-size: 100%; /* Respects user's browser settings */
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
color: var(--text-primary);
}
/* Minimum touch target size: 44px */
button,
.btn {
min-height: 44px;
min-width: 44px;
padding: 0.5rem 1rem;
}
/* Responsive text scaling */
h1 {
font-size: clamp(1.5rem, 4vw, 2.5rem);
}
h2 {
font-size: clamp(1.25rem, 3vw, 2rem);
}
h3 {
font-size: clamp(1.125rem, 2.5vw, 1.5rem);
}
/* Ensure sufficient spacing */
p,
ul,
ol {
margin-bottom: 1rem;
}
li {
margin-bottom: 0.25rem;
}
Dynamic Content
Loading States
<div class="loading-container" aria-live="polite" aria-busy="true">
<div class="spinner" aria-hidden="true"></div>
<span class="sr-only">Loading content, please wait...</span>
</div>
class AccessibleLoader {
constructor(container) {
this.container = container;
this.loadingMessage = container.querySelector(".sr-only");
}
show(message = "Loading content, please wait...") {
this.container.setAttribute("aria-busy", "true");
this.loadingMessage.textContent = message;
this.container.hidden = false;
}
hide() {
this.container.setAttribute("aria-busy", "false");
this.container.hidden = true;
}
updateMessage(message) {
this.loadingMessage.textContent = message;
}
}
Error Handling
class AccessibleErrorHandler {
constructor() {
this.errorContainer = document.getElementById("error-announcements");
}
announceError(message, type = "error") {
const errorElement = document.createElement("div");
errorElement.setAttribute("role", "alert");
errorElement.className = `error-message error-${type}`;
errorElement.textContent = message;
this.errorContainer.appendChild(errorElement);
// Remove after announcement
setTimeout(() => {
errorElement.remove();
}, 5000);
}
clearErrors() {
this.errorContainer.innerHTML = "";
}
}
// Usage
const errorHandler = new AccessibleErrorHandler();
errorHandler.announceError("Failed to save changes. Please try again.");
Testing & Validation
Automated Testing
// Using axe-core for accessibility testing
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
describe("Accessibility Tests", () => {
test("should not have accessibility violations", async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test("should have proper focus management", () => {
const { getByRole } = render(<Modal />);
const modal = getByRole("dialog");
// Test focus trap
fireEvent.keyDown(modal, { key: "Tab" });
expect(document.activeElement).toBe(getByRole("button", { name: "Close" }));
});
});
Manual Testing Checklist
class AccessibilityChecker {
static runChecklist() {
const checks = [
{
name: "Keyboard Navigation",
test: () => {
console.log(
"✓ Can you navigate the entire page using only the keyboard?",
);
console.log("✓ Are focus indicators visible and clear?");
console.log("✓ Is the tab order logical?");
},
},
{
name: "Screen Reader",
test: () => {
console.log("✓ Does content make sense when read aloud?");
console.log("✓ Are headings properly structured?");
console.log("✓ Are form labels clear and descriptive?");
},
},
{
name: "Visual Design",
test: () => {
console.log("✓ Is text contrast sufficient (4.5:1 minimum)?");
console.log("✓ Does content work at 200% zoom?");
console.log("✓ Are touch targets at least 44px?");
},
},
];
checks.forEach((check) => {
console.group(check.name);
check.test();
console.groupEnd();
});
}
}
Best Practices
Development Guidelines
- Semantic HTML First: Use proper HTML elements before adding ARIA
- Progressive Enhancement: Ensure basic functionality works without JavaScript
- Test Early and Often: Include accessibility testing in your development workflow
- Real User Testing: Test with actual users who rely on assistive technologies
Common Patterns
<!-- Button vs Link -->
<button onclick="saveData()">Save</button>
<!-- Actions -->
<a href="/profile">View Profile</a>
<!-- Navigation -->
<!-- Proper labeling -->
<label for="search">Search</label>
<input type="search" id="search" placeholder="Enter keywords..." />
<!-- Group related content -->
<fieldset>
<legend>Shipping Address</legend>
<!-- Address fields -->
</fieldset>
Performance Considerations
/* Respect user preferences */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #1a1a1a;
--text-color: #ffffff;
}
}
Conclusion
Accessibility is not a feature to be added later—it's a fundamental aspect of good web development. By following these patterns and making accessibility a core part of your development process, you create better experiences for all users.
Remember: accessibility benefits everyone, not just users with disabilities. Clear navigation, good contrast, and logical structure improve usability for all users, especially in challenging environments like bright sunlight or noisy spaces.
Start with semantic HTML, test with real users, and iterate based on feedback. Accessibility is a journey, not a destination.