Skip to main content

Web Accessibility (a11y): ARIA, Semantic HTML, and WCAG Guidelines

Master web accessibility with ARIA attributes, semantic HTML, and WCAG compliance. Learn to build inclusive websites that work for all users, including those using assistive technologies.

Table of Contents

Introduction

Web accessibility, often abbreviated as “a11y” (where “11” represents the 11 letters between “a” and “y”), is the practice of making websites usable by everyone, regardless of their abilities or disabilities. This includes people with visual, auditory, motor, or cognitive impairments, as well as those using assistive technologies like screen readers, voice navigation, or alternative input devices.

Accessibility isn’t just about compliance or avoiding legal issues—it’s about creating inclusive digital experiences that work for everyone. When you build accessible websites, you’re not only helping people with disabilities; you’re also improving the experience for users on mobile devices, those with slow internet connections, and anyone who benefits from clear, well-structured content.

The Web Content Accessibility Guidelines (WCAG) provide the international standard for web accessibility, and understanding how to implement semantic HTML, ARIA attributes, and proper keyboard navigation is essential for any modern web developer. This comprehensive guide will teach you everything you need to know about building accessible websites, from the fundamentals of semantic HTML to advanced ARIA patterns and WCAG compliance.

By the end of this guide, you’ll understand how to create websites that are not only functional and beautiful but also inclusive and accessible to all users, regardless of how they interact with the web.


Understanding Web Accessibility

Web accessibility ensures that websites and web applications can be used by people with diverse abilities and disabilities. It’s built on four core principles defined by WCAG: Perceivable, Operable, Understandable, and Robust (often remembered as POUR).

The Four Principles of Accessibility (POUR)

Perceivable: Information and user interface components must be presentable to users in ways they can perceive. This means:

  • Text alternatives for images and media
  • Captions and transcripts for audio content
  • Sufficient color contrast
  • Text that can be resized without loss of functionality

Operable: User interface components and navigation must be operable. This includes:

  • Keyboard accessibility (all functionality available via keyboard)
  • No content that causes seizures (no flashing)
  • Enough time for users to read and use content
  • Clear navigation and focus indicators

Understandable: Information and the operation of user interface must be understandable. This means:

  • Readable and predictable text
  • Consistent navigation and functionality
  • Clear error messages and instructions
  • Helpful form labels and instructions

Operable: Content must be robust enough to be interpreted reliably by a wide variety of user agents, including assistive technologies. This requires:

  • Valid, semantic HTML
  • Proper use of ARIA when needed
  • Forward compatibility with new technologies

Why Accessibility Matters

Accessibility benefits everyone, not just people with disabilities:

  • Legal Compliance: Many countries have laws requiring accessible websites (ADA in the US, EN 301 549 in Europe)
  • Broader Audience: Over 1 billion people worldwide have some form of disability
  • Better SEO: Search engines favor accessible, semantic HTML
  • Improved UX: Accessible sites are often more usable for everyone
  • Mobile Users: Many accessibility practices improve mobile experiences
  • Future-Proof: Accessible code is more maintainable and robust

Types of Disabilities to Consider

When building accessible websites, consider these categories:

  1. Visual: Blindness, low vision, color blindness
  2. Auditory: Deafness, hard of hearing
  3. Motor: Limited fine motor control, paralysis
  4. Cognitive: Learning disabilities, attention disorders, memory issues

Each category requires different considerations, but many accessibility practices benefit multiple groups simultaneously.


Semantic HTML: The Foundation of Accessibility

Semantic HTML uses HTML elements that convey meaning about the content they contain. This is the foundation of web accessibility because assistive technologies rely on semantic markup to understand and navigate web pages.

What Makes HTML Semantic?

Semantic HTML elements describe their meaning in a way that’s both human-readable and machine-readable. Non-semantic elements like <div> and <span> don’t convey any meaning about their content, while semantic elements like <nav>, <article>, and <button> clearly communicate their purpose.

Essential Semantic Elements

Document Structure Elements

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Accessible Web Page</title>
</head>
<body>
<!-- Header: Site-wide header content -->
<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: Primary content of the page -->
<main>
<!-- Article: Standalone content piece -->
<article>
<header>
<h1>Article Title</h1>
<p>
Published on <time datetime="2024-12-23">December 23, 2024</time>
</p>
</header>
<section>
<h2>Section Heading</h2>
<p>Content goes here...</p>
</section>
</article>
<!-- Aside: Complementary content -->
<aside aria-label="Related articles">
<h2>Related Articles</h2>
<!-- Related content -->
</aside>
</main>
<!-- Footer: Site-wide footer content -->
<footer>
<p>&copy; 2024 Your Company</p>
</footer>
</body>
</html>

Best Practice: Always use semantic HTML elements for their intended purpose. Use <header> for headers, <nav> for navigation, <main> for main content, <article> for articles, <section> for sections, <aside> for sidebars, and <footer> for footers.

Interactive Elements

<!-- ✅ Good: Semantic button element -->
<button type="button" onclick="handleClick()">Click Me</button>
<!-- ❌ Bad: Div styled as button -->
<div class="button" onclick="handleClick()">Click Me</div>
<!-- ✅ Good: Semantic link -->
<a href="/about">Learn More</a>
<!-- ❌ Bad: Div styled as link -->
<div class="link" onclick="window.location='/about'">Learn More</div>

💡 Tip: Always use semantic interactive elements (<button>, <a>, <input>, etc.) instead of styling non-interactive elements to look like buttons or links. Semantic elements have built-in keyboard accessibility and screen reader support.

Form Elements

<!-- ✅ Good: Proper form structure with labels -->
<form>
<fieldset>
<legend>Contact Information</legend>
<div>
<label for="name">Full Name</label>
<input type="text" id="name" name="name" required />
</div>
<div>
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
required
aria-describedby="email-help"
/>
<small id="email-help">We'll never share your email</small>
</div>
<div>
<label>
<input type="checkbox" name="newsletter" />
Subscribe to newsletter
</label>
</div>
<button type="submit">Submit</button>
</fieldset>
</form>

🔍 Deep Dive: The <label> element creates an explicit association between the label text and the form input. When users click the label, focus moves to the associated input. The for attribute links the label to the input’s id, or you can nest the input inside the label.

Lists and Navigation

<!-- ✅ Good: Semantic navigation with list -->
<nav aria-label="Main navigation">
<ul>
<li><a href="/" aria-current="page">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
<!-- ✅ Good: Semantic list for content -->
<ul>
<li>First item</li>
<li>Second item</li>
<li>Third item</li>
</ul>
<!-- ✅ Good: Description list for key-value pairs -->
<dl>
<dt>Term</dt>
<dd>Definition of the term</dd>
<dt>Another Term</dt>
<dd>Another definition</dd>
</dl>

Headings Hierarchy

Proper heading hierarchy is crucial for screen reader users to navigate content:

<!-- ✅ Good: Proper heading hierarchy -->
<article>
<h1>Main Article Title</h1>
<section>
<h2>Section Title</h2>
<p>Content...</p>
<h3>Subsection Title</h3>
<p>More content...</p>
</section>
<section>
<h2>Another Section</h2>
<p>Content...</p>
</section>
</article>
<!-- ❌ Bad: Skipping heading levels -->
<article>
<h1>Main Title</h1>
<h3>Skipped h2 - Bad!</h3>
<!-- Should be h2 -->
<h2>This should come before h3</h2>
</article>

⚠️ Important: Never skip heading levels (e.g., going from <h1> to <h3>). Screen readers use headings to create a document outline, and skipping levels breaks this navigation structure.

Language Attributes

Always specify the language of your content:

<!-- ✅ Good: Language specified -->
<html lang="en">
<body>
<p>This is English content.</p>
<p lang="es">Este es contenido en español.</p>
</body>
</html>

The lang attribute helps screen readers pronounce content correctly and helps search engines understand your content. For more on SEO best practices, see our guide on on-page SEO optimization.


ARIA Attributes: Enhancing Accessibility

ARIA (Accessible Rich Internet Applications) is a set of attributes that enhance HTML’s accessibility when semantic HTML alone isn’t sufficient. ARIA doesn’t change the visual appearance or behavior of elements—it only provides additional information to assistive technologies.

When to Use ARIA

ARIA should be used when:

  • Semantic HTML doesn’t provide enough information
  • Building custom interactive components
  • Enhancing existing elements with additional context
  • Creating dynamic content that changes

⚠️ Important: Don’t use ARIA when semantic HTML can do the job. The first rule of ARIA is: “If you can use a native HTML element or attribute with the semantics and behavior you require already built in, instead of re-purposing an element and adding an ARIA role, state or property to make it accessible, then do so.”

ARIA Roles

Roles define what an element is or does:

<!-- ✅ Good: Using semantic button (no ARIA needed) -->
<button>Click Me</button>
<!-- ✅ Good: Custom component needs ARIA role -->
<div role="button" tabindex="0" onclick="handleClick()">Custom Button</div>
<!-- ✅ Good: Landmark roles for page structure -->
<div role="banner">Header content</div>
<div role="main">Main content</div>
<div role="contentinfo">Footer content</div>
<div role="complementary">Sidebar content</div>
<div role="navigation">Navigation content</div>
<!-- ✅ Good: Widget roles -->
<div role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Dialog Title</h2>
<p>Dialog content...</p>
</div>
<div role="alert" aria-live="assertive">Important message that interrupts</div>

ARIA States and Properties

States and properties provide additional information about elements:

<!-- aria-label: Provides accessible name -->
<button aria-label="Close dialog">
<span aria-hidden="true">×</span>
</button>
<!-- aria-labelledby: References element that provides label -->
<div role="region" aria-labelledby="section-title">
<h2 id="section-title">Section Title</h2>
<p>Content...</p>
</div>
<!-- aria-describedby: References element that provides description -->
<input
type="text"
aria-describedby="help-text"
aria-invalid="true"
aria-required="true"
/>
<span id="help-text">Please enter a valid email address</span>
<!-- aria-hidden: Hides decorative elements from screen readers -->
<div aria-hidden="true">
<img src="decoration.png" alt="" />
</div>
<!-- aria-expanded: Indicates if collapsible content is open -->
<button aria-expanded="false" aria-controls="menu">Menu</button>
<ul id="menu" hidden>
<li>Item 1</li>
<li>Item 2</li>
</ul>

Common ARIA Patterns

<!-- ✅ Good: Accessible modal dialog -->
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
>
<h2 id="dialog-title">Confirm Action</h2>
<p id="dialog-description">Are you sure you want to delete this item?</p>
<button onclick="closeDialog()">Cancel</button>
<button onclick="confirmAction()">Delete</button>
</div>
<!-- JavaScript to manage focus -->
<script>
function openDialog() {
const dialog = document.querySelector('[role="dialog"]');
dialog.hidden = false;
// Trap focus within dialog
dialog.querySelector("button").focus();
}
function closeDialog() {
const dialog = document.querySelector('[role="dialog"]');
dialog.hidden = true;
// Return focus to trigger element
document.querySelector("[data-trigger]").focus();
}
</script>

Live Regions

Live regions announce dynamic content changes to screen readers:

<!-- aria-live="polite": Announces when user is idle -->
<div aria-live="polite" aria-atomic="true" id="status">
<!-- Status messages appear here -->
</div>
<!-- aria-live="assertive": Announces immediately -->
<div aria-live="assertive" id="alert">
<!-- Urgent alerts appear here -->
</div>
<script>
function updateStatus(message) {
const status = document.getElementById("status");
status.textContent = message;
// Screen reader will announce the message
}
</script>

Tabs

<!-- ✅ Good: Accessible tab interface -->
<div role="tablist" aria-label="Product information">
<button
role="tab"
aria-selected="true"
aria-controls="panel-1"
id="tab-1"
tabindex="0"
>
Description
</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-2"
id="tab-2"
tabindex="-1"
>
Specifications
</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-3"
id="tab-3"
tabindex="-1"
>
Reviews
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1" tabindex="0">
<p>Product description content...</p>
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden tabindex="0">
<p>Specifications content...</p>
</div>
<div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden tabindex="0">
<p>Reviews content...</p>
</div>

Form Validation

<!-- ✅ Good: Accessible form with validation -->
<form>
<div>
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
aria-required="true"
aria-invalid="false"
aria-describedby="email-error email-help"
/>
<span id="email-help">We'll never share your email</span>
<span id="email-error" role="alert" aria-live="polite"></span>
</div>
<button type="submit">Submit</button>
</form>
<script>
const emailInput = document.getElementById("email");
const errorSpan = document.getElementById("email-error");
emailInput.addEventListener("blur", function () {
if (!this.validity.valid) {
this.setAttribute("aria-invalid", "true");
errorSpan.textContent = "Please enter a valid email address";
} else {
this.setAttribute("aria-invalid", "false");
errorSpan.textContent = "";
}
});
</script>

💡 Tip: Use aria-invalid="true" to indicate form validation errors, and aria-describedby to link error messages to inputs. This helps screen reader users understand what went wrong and how to fix it.


Keyboard Navigation and Focus Management

Keyboard navigation is essential for users who cannot use a mouse, including those with motor disabilities and power users who prefer keyboard shortcuts. Every interactive element must be accessible via keyboard.

Focus Indicators

Visible focus indicators are crucial for keyboard users:

/* ✅ Good: Visible focus indicator */
button:focus,
a:focus,
input:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* ✅ Good: Custom focus styles */
.custom-button:focus {
outline: 3px solid #0066cc;
outline-offset: 3px;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.3);
}
/* ❌ Bad: Removing focus indicator */
button:focus {
outline: none; /* Never do this! */
}

⚠️ Critical: Never remove focus indicators with outline: none without providing an alternative. If you must remove the default outline, always add a custom, highly visible focus style.

Tab Order

The tab order should follow the visual order and logical flow of the page:

<!-- ✅ Good: Logical tab order -->
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
<main>
<h1>Page Title</h1>
<form>
<input type="text" placeholder="Name" />
<input type="email" placeholder="Email" />
<button type="submit">Submit</button>
</form>
</main>

Tabindex Attribute

Use tabindex carefully:

<!-- ✅ Good: Natural tab order (no tabindex needed) -->
<button>Button 1</button>
<button>Button 2</button>
<!-- ✅ Good: tabindex="0" - Include in natural tab order -->
<div role="button" tabindex="0" onclick="handleClick()">Custom Button</div>
<!-- ✅ Good: tabindex="-1" - Remove from tab order but allow programmatic focus -->
<div role="dialog" tabindex="-1" id="modal">
<!-- Focus programmatically when modal opens -->
</div>
<!-- ❌ Bad: tabindex > 0 - Avoid positive tabindex values -->
<div tabindex="5">Don't do this!</div>

🔍 Deep Dive:

  • tabindex="0": Element is included in natural tab order
  • tabindex="-1": Element can receive focus programmatically but is excluded from tab order
  • tabindex="1+": Creates a custom tab order, which can confuse users—avoid this

Skip links allow keyboard users to jump to main content:

<!-- ✅ Good: Skip link for main content -->
<a href="#main-content" class="skip-link"> Skip to main content </a>
<header>
<!-- Navigation -->
</header>
<main id="main-content">
<!-- Main content -->
</main>
<style>
.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;
}
</style>

Focus Management in JavaScript

Manage focus when creating dynamic content:

// ✅ Good: Focus management for modals
function openModal() {
const modal = document.getElementById("modal");
modal.hidden = false;
// Focus first focusable element in modal
const firstFocusable = modal.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus();
// Trap focus within modal
trapFocus(modal);
}
function closeModal() {
const modal = document.getElementById("modal");
modal.hidden = true;
// Return focus to trigger element
const trigger = document.querySelector("[data-modal-trigger]");
trigger?.focus();
}
// Focus trap implementation
function trapFocus(container) {
const focusableElements = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
container.addEventListener("keydown", function (e) {
if (e.key === "Tab") {
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
// Escape to close
if (e.key === "Escape") {
closeModal();
}
});
}

Keyboard Shortcuts

Provide keyboard shortcuts for common actions:

<!-- ✅ Good: Keyboard shortcuts with aria-keyshortcuts -->
<button aria-keyshortcuts="Ctrl+S" onclick="save()">Save</button>
<script>
document.addEventListener("keydown", function (e) {
// Ctrl+S or Cmd+S to save
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
save();
}
});
</script>

💡 Tip: Always provide a way for users to discover keyboard shortcuts, either through a help menu or documentation. Don’t rely solely on aria-keyshortcuts for discovery.


Screen Readers and Assistive Technologies

Screen readers are software applications that read aloud content displayed on a computer screen. Understanding how screen readers work helps you create better accessible experiences.

How Screen Readers Work

Screen readers:

  1. Parse the DOM and accessibility tree
  2. Create a linear representation of content
  3. Announce content changes and user interactions
  4. Provide navigation shortcuts (headings, landmarks, links, etc.)

Screen Reader Navigation

Users navigate with keyboard shortcuts:

  • Headings: H key to jump between headings
  • Links: L key to jump between links
  • Landmarks: D key to jump between landmarks
  • Forms: F key to jump between form fields
  • Lists: L key to jump between lists
  • Tables: T key to jump between tables

This is why semantic HTML and proper structure are so important!

Testing with Screen Readers

Common screen readers to test with:

  1. NVDA (Windows, free)
  2. JAWS (Windows, paid)
  3. VoiceOver (macOS/iOS, built-in)
  4. TalkBack (Android, built-in)
  5. Narrator (Windows, built-in)

Screen Reader Best Practices

<!-- ✅ Good: Descriptive link text -->
<a href="/products">View our product catalog</a>
<!-- ❌ Bad: Generic link text -->
<a href="/products">Click here</a>
<a href="/products">Read more</a>
<!-- ✅ Good: Descriptive button text -->
<button>Add to cart</button>
<!-- ❌ Bad: Generic button text -->
<button>Submit</button>
<button>OK</button>
<!-- ✅ Good: Contextual information -->
<article>
<h2>Product Review</h2>
<p>By <span class="author">John Doe</span></p>
<time datetime="2024-12-23">December 23, 2024</time>
</article>
<!-- ✅ Good: Hidden text for screen readers when needed -->
<button>
<span aria-hidden="true">🗑️</span>
<span class="sr-only">Delete item</span>
</button>
<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-width: 0;
}
</style>

ARIA Live Regions

Use live regions to announce dynamic content:

<!-- ✅ Good: Status updates -->
<div aria-live="polite" aria-atomic="true" id="status">
<!-- Status messages -->
</div>
<!-- ✅ Good: Error alerts -->
<div role="alert" aria-live="assertive">
<!-- Critical errors -->
</div>
<script>
function showStatus(message) {
const status = document.getElementById("status");
status.textContent = message;
// Screen reader will announce: "Status: [message]"
}
function showError(message) {
const alert = document.createElement("div");
alert.setAttribute("role", "alert");
alert.setAttribute("aria-live", "assertive");
alert.textContent = message;
document.body.appendChild(alert);
// Screen reader will immediately announce the error
}
</script>

🔍 Deep Dive:

  • aria-live="polite": Announces when user is idle (for status updates)
  • aria-live="assertive": Announces immediately (for critical alerts)
  • aria-atomic="true": Announces entire region when any part changes
  • aria-atomic="false": Announces only changed parts

WCAG Guidelines: The Standard for Accessibility

The Web Content Accessibility Guidelines (WCAG) are the international standard for web accessibility. WCAG 2.1 has three levels of conformance: A (minimum), AA (recommended), and AAA (enhanced).

WCAG 2.1 Success Criteria

Key success criteria at each level:

Level A (Minimum)

  • 1.1.1 Non-text Content: All images have alt text
  • 2.1.1 Keyboard: All functionality available via keyboard
  • 2.4.2 Page Titled: Pages have descriptive titles
  • 3.3.1 Error Identification: Errors are identified and described
  • 1.4.3 Contrast (Minimum): Text contrast ratio of at least 4.5:1 (3:1 for large text)
  • 2.4.6 Headings and Labels: Headings and labels are descriptive
  • 2.4.7 Focus Visible: Keyboard focus indicator is visible
  • 4.1.2 Name, Role, Value: UI components have accessible names

Level AAA (Enhanced)

  • 1.4.6 Contrast (Enhanced): Text contrast ratio of at least 7:1 (4.5:1 for large text)
  • 2.4.8 Location: Information about user’s location in website
  • 3.1.4 Abbreviations: Mechanism to identify abbreviations

Perceivable Guidelines

<!-- ✅ Good: Text alternatives for images -->
<img src="chart.png" alt="Sales increased 25% from Q1 to Q2" />
<!-- ✅ Good: Decorative images -->
<img src="decoration.png" alt="" role="presentation" />
<!-- ✅ Good: Complex images with long description -->
<img
src="diagram.png"
alt="Network architecture diagram"
longdesc="network-architecture.html"
/>
<!-- ✅ Good: Captions for video -->
<video controls>
<source src="video.mp4" type="video/mp4" />
<track kind="captions" src="captions.vtt" srclang="en" label="English" />
</video>

Operable Guidelines

<!-- ✅ Good: No content that flashes more than 3 times per second -->
<style>
/* Avoid animations that flash rapidly */
@keyframes flash {
/* Safe animation */
}
</style>
<!-- ✅ Good: Sufficient time limits with ability to extend -->
<div data-timeout="300">
<p>Your session will expire in 5 minutes.</p>
<button onclick="extendSession()">Extend Session</button>
</div>
<!-- ✅ Good: Pause, stop, hide controls for moving content -->
<div aria-live="polite">
<button aria-label="Pause animation" onclick="pauseAnimation()">Pause</button>
<div id="animated-content">Animated content</div>
</div>

Understandable Guidelines

<!-- ✅ Good: Language of page specified -->
<html lang="en">
<!-- ✅ Good: Language changes identified -->
<p>This is English.</p>
<p lang="es">Este es español.</p>
<!-- ✅ Good: Consistent navigation -->
<nav aria-label="Main navigation">
<!-- Same navigation structure on every page -->
</nav>
<!-- ✅ Good: Clear error messages -->
<input type="email" aria-invalid="true" aria-describedby="error" />
<span id="error" role="alert">
Please enter a valid email address (example: user@domain.com)
</span>
</html>

Robust Guidelines

<!-- ✅ Good: Valid HTML -->
<!DOCTYPE html>
<html lang="en">
<!-- Proper HTML structure -->
</html>
<!-- ✅ Good: Proper use of ARIA -->
<button aria-label="Close dialog">×</button>
<!-- ✅ Good: Forward compatibility -->
<!-- Use standard HTML and ARIA, avoid proprietary solutions -->

💡 Tip: Most organizations aim for WCAG 2.1 Level AA compliance, which covers the majority of accessibility requirements. Level AAA is often difficult to achieve for all content and may not be necessary for all use cases.


Forms and Input Accessibility

Forms are one of the most critical areas for accessibility. Properly labeled, structured forms are essential for users with disabilities.

Labeling Form Controls

<!-- ✅ Good: Explicit label association -->
<label for="username">Username</label>
<input type="text" id="username" name="username" />
<!-- ✅ Good: Implicit label (input nested in label) -->
<label>
Username
<input type="text" name="username" />
</label>
<!-- ✅ Good: aria-label for icon-only buttons -->
<button type="submit" aria-label="Submit form">
<span aria-hidden="true">✓</span>
</button>
<!-- ✅ Good: aria-labelledby for complex labels -->
<div id="username-label">
<span>Username</span>
<span class="required">*</span>
</div>
<input
type="text"
id="username"
name="username"
aria-labelledby="username-label"
aria-required="true"
/>

Required Fields

<!-- ✅ Good: Visual and programmatic indication -->
<label for="email">
Email Address
<span aria-label="required">*</span>
</label>
<input type="email" id="email" name="email" required aria-required="true" />
<!-- ✅ Good: Using aria-required for custom validation -->
<input
type="text"
id="custom-field"
aria-required="true"
aria-invalid="false"
/>

Error Messages

<!-- ✅ Good: Accessible error messages -->
<form>
<div>
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
aria-required="true"
aria-invalid="false"
aria-describedby="email-error email-help"
/>
<span id="email-help">We'll never share your email</span>
<span
id="email-error"
role="alert"
aria-live="polite"
class="error-message"
></span>
</div>
<button type="submit">Submit</button>
</form>
<script>
const emailInput = document.getElementById("email");
const errorSpan = document.getElementById("email-error");
emailInput.addEventListener("blur", function () {
if (!this.validity.valid) {
this.setAttribute("aria-invalid", "true");
errorSpan.textContent = "Please enter a valid email address";
} else {
this.setAttribute("aria-invalid", "false");
errorSpan.textContent = "";
}
});
emailInput.addEventListener("input", function () {
// Clear error on input
if (this.getAttribute("aria-invalid") === "true") {
this.setAttribute("aria-invalid", "false");
errorSpan.textContent = "";
}
});
</script>

Fieldsets and Legends

Use fieldsets to group related form controls:

<!-- ✅ Good: Grouped form controls -->
<form>
<fieldset>
<legend>Contact Information</legend>
<div>
<label for="name">Full Name</label>
<input type="text" id="name" name="name" />
</div>
<div>
<label for="email">Email</label>
<input type="email" id="email" name="email" />
</div>
</fieldset>
<fieldset>
<legend>Shipping Address</legend>
<div>
<label for="street">Street Address</label>
<input type="text" id="street" name="street" />
</div>
<div>
<label for="city">City</label>
<input type="text" id="city" name="city" />
</div>
</fieldset>
<button type="submit">Submit</button>
</form>

Help Text

<!-- ✅ Good: Help text associated with input -->
<div>
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
aria-describedby="password-help"
/>
<span id="password-help">
Password must be at least 8 characters and include uppercase, lowercase, and
numbers.
</span>
</div>

Radio Buttons and Checkboxes

<!-- ✅ Good: Grouped radio buttons -->
<fieldset>
<legend>Preferred Contact Method</legend>
<div>
<input type="radio" id="email-contact" name="contact" value="email" />
<label for="email-contact">Email</label>
</div>
<div>
<input type="radio" id="phone-contact" name="contact" value="phone" />
<label for="phone-contact">Phone</label>
</div>
<div>
<input type="radio" id="mail-contact" name="contact" value="mail" />
<label for="mail-contact">Mail</label>
</div>
</fieldset>
<!-- ✅ Good: Checkbox with label -->
<div>
<input type="checkbox" id="newsletter" name="newsletter" />
<label for="newsletter">Subscribe to newsletter</label>
</div>

Images and Media Accessibility

Images and media content need proper alternatives to be accessible to users who cannot see them.

Alt Text Best Practices

<!-- ✅ Good: Descriptive alt text -->
<img
src="sales-chart.png"
alt="Sales increased from $50,000 in Q1 to $75,000 in Q2"
/>
<!-- ✅ Good: Decorative image -->
<img src="decoration.png" alt="" role="presentation" />
<!-- ✅ Good: Functional image (icon button) -->
<button>
<img src="search-icon.png" alt="Search" />
</button>
<!-- ✅ Good: Complex image with long description -->
<figure>
<img
src="network-diagram.png"
alt="Network architecture diagram showing server connections"
/>
<figcaption>
Network architecture: Three web servers connected to a load balancer, which
connects to a database cluster.
</figcaption>
</figure>

💡 Tip: Alt text should be:

  • Concise but descriptive (usually under 125 characters)
  • Context-specific (same image may need different alt text in different contexts)
  • Not redundant (don’t say “image of” or “picture of”)
  • Meaningful (describe what’s important about the image)

Decorative Images

<!-- ✅ Good: Decorative images -->
<img src="divider.png" alt="" role="presentation" />
<img src="background-pattern.png" alt="" aria-hidden="true" />
<!-- CSS background images are automatically ignored by screen readers -->
<div class="decorative-background"></div>

Image Maps

<!-- ✅ Good: Accessible image map -->
<img
src="world-map.png"
alt="World map showing office locations"
usemap="#offices"
/>
<map name="offices">
<area
shape="rect"
coords="100,50,200,100"
href="/offices/new-york"
alt="New York office"
/>
<area
shape="rect"
coords="300,150,400,200"
href="/offices/london"
alt="London office"
/>
</map>

Video Accessibility

<!-- ✅ Good: Video with captions and transcripts -->
<video controls>
<source src="tutorial.mp4" type="video/mp4" />
<track
kind="captions"
src="tutorial-captions.vtt"
srclang="en"
label="English"
default
/>
<track kind="descriptions" src="tutorial-descriptions.vtt" srclang="en" />
</video>
<a href="tutorial-transcript.txt">View transcript</a>

Audio Accessibility

<!-- ✅ Good: Audio with transcript -->
<audio controls>
<source src="podcast.mp3" type="audio/mpeg" />
</audio>
<a href="podcast-transcript.txt">Read transcript</a>

SVG Accessibility

<!-- ✅ Good: Accessible SVG -->
<svg role="img" aria-labelledby="chart-title" aria-describedby="chart-desc">
<title id="chart-title">Sales Growth Chart</title>
<desc id="chart-desc">
Line chart showing 25% growth from January to December
</desc>
<!-- SVG content -->
</svg>
<!-- ✅ Good: Decorative SVG -->
<svg aria-hidden="true">
<!-- Decorative content -->
</svg>
<!-- ✅ Good: SVG as icon -->
<svg role="img" aria-label="Close dialog">
<!-- Icon content -->
</svg>

Color Contrast and Visual Accessibility

Color contrast is crucial for users with low vision or color blindness. WCAG requires specific contrast ratios for text.

Contrast Ratios

WCAG 2.1 contrast requirements:

  • Level AA (Normal Text): 4.5:1 contrast ratio
  • Level AA (Large Text): 3:1 contrast ratio (18pt+ or 14pt+ bold)
  • Level AAA (Normal Text): 7:1 contrast ratio
  • Level AAA (Large Text): 4.5:1 contrast ratio

Checking Contrast

/* ✅ Good: High contrast text */
.text-primary {
color: #000000; /* Black */
background-color: #ffffff; /* White */
/* Contrast ratio: 21:1 */
}
.text-secondary {
color: #333333; /* Dark gray */
background-color: #ffffff; /* White */
/* Contrast ratio: 12.6:1 */
}
/* ✅ Good: AA compliant */
.button {
color: #ffffff; /* White */
background-color: #0066cc; /* Blue */
/* Contrast ratio: 4.5:1 (meets AA) */
}
/* ❌ Bad: Low contrast */
.text-low-contrast {
color: #cccccc; /* Light gray */
background-color: #ffffff; /* White */
/* Contrast ratio: 1.6:1 (fails AA) */
}

💡 Tip: Use tools like WebAIM Contrast Checker, axe DevTools, or browser extensions to verify contrast ratios. Don’t rely on visual estimation.

Color and Information

Never rely solely on color to convey information:

<!-- ❌ Bad: Color-only indication -->
<p>Status: <span style="color: green;">Active</span></p>
<!-- ✅ Good: Color + text/icon -->
<p>
Status:
<span style="color: green;" aria-label="Active">
<span aria-hidden="true">●</span> Active
</span>
</p>
<!-- ✅ Good: Color + icon -->
<p>
Status:
<span style="color: green;">
<span aria-hidden="true">✓</span>
<span class="sr-only">Active</span>
</span>
</p>
<!-- ❌ Bad: Color-only form validation -->
<input type="text" style="border-color: red;" />
<!-- ✅ Good: Color + text + icon -->
<input type="text" aria-invalid="true" aria-describedby="error" />
<span id="error" role="alert">
<span aria-hidden="true">⚠️</span>
Please enter a valid email address
</span>

Focus Indicators

Ensure focus indicators have sufficient contrast:

/* ✅ Good: High contrast focus indicator */
button:focus {
outline: 3px solid #0066cc;
outline-offset: 2px;
/* Outline color has sufficient contrast */
}
/* ✅ Good: Custom focus styles */
.custom-button:focus {
outline: 3px solid #0066cc;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.3);
background-color: #0052a3; /* Darker for contrast */
}

Responsive Text Sizing

Ensure text can be resized up to 200% without loss of functionality:

/* ✅ Good: Responsive text sizing */
body {
font-size: 16px; /* Base size */
}
h1 {
font-size: 2rem; /* Scales with root */
}
/* ✅ Good: Flexible layouts */
.container {
max-width: 100%;
overflow-x: auto; /* Allow horizontal scroll if needed */
}
/* ❌ Bad: Fixed sizes that break layout */
.text {
font-size: 12px; /* Too small, doesn't scale well */
width: 300px; /* Fixed width may cause issues */
}

Testing Accessibility

Testing is essential to ensure your website is accessible. Use a combination of automated tools and manual testing.

Automated Testing Tools

Terminal window
# axe DevTools (browser extension)
# Available for Chrome, Firefox, Edge
# WAVE (browser extension)
# Web Accessibility Evaluation Tool
# Lighthouse (built into Chrome DevTools)
# Run accessibility audit

Manual Testing Checklist

  1. Keyboard Navigation

    • Tab through all interactive elements
    • Verify focus indicators are visible
    • Test all functionality with keyboard only
    • Check skip links work
  2. Screen Reader Testing

    • Test with NVDA (Windows) or VoiceOver (macOS)
    • Verify all content is announced correctly
    • Check heading navigation works
    • Verify form labels are announced
  3. Visual Testing

    • Test with browser zoom at 200%
    • Verify color contrast ratios
    • Check that information isn’t conveyed by color alone
    • Test with high contrast mode
  4. Form Testing

    • Verify all inputs have labels
    • Test error messages are announced
    • Check required fields are indicated
    • Verify form validation is accessible

Testing with axe DevTools

// Automated testing with axe-core
import axe from "axe-core";
// Run accessibility audit
axe.run(document, (err, results) => {
if (err) throw err;
console.log("Violations:", results.violations);
console.log("Incomplete:", results.incomplete);
console.log("Passes:", results.passes);
});
// Common violations to check for:
// - Missing alt text on images
// - Missing form labels
// - Insufficient color contrast
// - Missing ARIA labels
// - Keyboard accessibility issues

Browser DevTools

// Check accessibility tree in Chrome DevTools
// Elements panel → Accessibility tab
// Check computed ARIA properties
// Verify roles, labels, and states

Real User Testing

Consider testing with actual users who use assistive technologies:

  • Screen reader users
  • Keyboard-only users
  • Users with motor disabilities
  • Users with cognitive disabilities

💡 Tip: Automated tools catch about 30% of accessibility issues. Always combine automated testing with manual testing and user testing for comprehensive coverage.


Common Accessibility Mistakes

Avoid these common accessibility mistakes:

Mistake 1: Missing Alt Text

<!-- ❌ Bad: Missing alt text -->
<img src="chart.png" />
<!-- ✅ Good: Descriptive alt text -->
<img src="chart.png" alt="Sales increased 25% from Q1 to Q2" />

Mistake 2: Poor Focus Indicators

/* ❌ Bad: Removing focus indicator */
button:focus {
outline: none;
}
/* ✅ Good: Visible focus indicator */
button:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
<!-- ❌ Bad: Generic link text -->
<a href="/products">Click here</a>
<a href="/about">Read more</a>
<!-- ✅ Good: Descriptive link text -->
<a href="/products">View our product catalog</a>
<a href="/about">Learn more about our company</a>

Mistake 4: Missing Form Labels

<!-- ❌ Bad: Missing label -->
<input type="text" placeholder="Name" />
<!-- ✅ Good: Proper label -->
<label for="name">Full Name</label>
<input type="text" id="name" name="name" />

Mistake 5: Color-Only Information

<!-- ❌ Bad: Color-only indication -->
<span style="color: red;">Error</span>
<!-- ✅ Good: Color + text/icon -->
<span style="color: red;" role="alert">
<span aria-hidden="true">⚠️</span> Error: Invalid input
</span>

Mistake 6: Improper Heading Hierarchy

<!-- ❌ Bad: Skipping heading levels -->
<h1>Title</h1>
<h3>Subtitle</h3>
<!-- Skipped h2 -->
<!-- ✅ Good: Proper hierarchy -->
<h1>Title</h1>
<h2>Subtitle</h2>
<h3>Subsection</h3>

Mistake 7: Missing ARIA Labels

<!-- ❌ Bad: Icon button without label -->
<button>
<span>×</span>
</button>
<!-- ✅ Good: Icon button with aria-label -->
<button aria-label="Close dialog">
<span aria-hidden="true">×</span>
</button>

Mistake 8: Inaccessible Custom Components

<!-- ❌ Bad: Custom component without ARIA -->
<div class="custom-button" onclick="handleClick()">Click Me</div>
<!-- ✅ Good: Accessible custom component -->
<div
role="button"
tabindex="0"
aria-label="Click Me"
onclick="handleClick()"
onkeydown="if(event.key==='Enter') handleClick()"
>
Click Me
</div>

Accessibility in Modern Frameworks

Modern frameworks like React and Vue have built-in support for accessibility, but you still need to use them correctly.

React Accessibility

// ✅ Good: Semantic HTML in React
function Button({ children, onClick }) {
return <button onClick={onClick}>{children}</button>;
}
// ✅ Good: ARIA attributes in React
function Modal({ isOpen, title, children, onClose }) {
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="Close dialog">
×
</button>
</div>
);
}
// ✅ Good: Form with labels
function ContactForm() {
return (
<form>
<div>
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
/>
</div>
<button type="submit">Submit</button>
</form>
);
}
// ✅ Good: Managing focus
import { useEffect, useRef } from "react";
function Modal({ isOpen, children }) {
const firstFocusableRef = useRef(null);
useEffect(() => {
if (isOpen && firstFocusableRef.current) {
firstFocusableRef.current.focus();
}
}, [isOpen]);
return (
<div role="dialog" aria-modal="true">
<button ref={firstFocusableRef}>First Button</button>
{children}
</div>
);
}

Vue Accessibility

<!-- ✅ Good: Semantic HTML in Vue -->
<template>
<button @click="handleClick" :aria-label="buttonLabel">
{{ buttonText }}
</button>
</template>
<script setup>
const buttonText = "Click Me";
const buttonLabel = "Click this button to submit the form";
</script>
<!-- ✅ Good: Form with labels -->
<template>
<form @submit.prevent="handleSubmit">
<div>
<label for="email">Email Address</label>
<input
type="email"
id="email"
v-model="email"
:aria-invalid="hasError"
aria-describedby="email-error"
/>
<span id="email-error" v-if="hasError" role="alert">
Please enter a valid email
</span>
</div>
<button type="submit">Submit</button>
</form>
</template>

Framework-Specific Considerations

  • React: Use htmlFor instead of for, manage focus with refs and effects
  • Vue: Use :aria-* bindings for dynamic ARIA attributes
  • All Frameworks: Ensure server-side rendering maintains semantic HTML

💡 Tip: Many frameworks have accessibility libraries (like @react-aria or vue-announcer) that can help with common patterns. However, understanding the fundamentals of semantic HTML and ARIA is still essential.


Conclusion

Web accessibility is not optional—it’s a fundamental requirement for creating inclusive digital experiences. By implementing semantic HTML, proper ARIA attributes, keyboard navigation, and following WCAG guidelines, you can build websites that work for everyone.

Key Takeaways

  1. Start with semantic HTML: Use the right elements for the right purpose
  2. Enhance with ARIA when needed: Don’t use ARIA when HTML can do the job
  3. Ensure keyboard accessibility: All functionality must work without a mouse
  4. Test thoroughly: Combine automated tools with manual and user testing
  5. Follow WCAG guidelines: Aim for Level AA compliance as a minimum
  6. Consider all users: Think beyond your own experience and abilities

Next Steps

  • Audit your existing websites for accessibility issues
  • Implement the practices covered in this guide
  • Test with screen readers and keyboard navigation
  • Consider accessibility from the start of new projects
  • Stay updated with WCAG guidelines and best practices

Remember, accessibility is an ongoing process, not a one-time checklist. As you build and maintain websites, continue to prioritize accessibility and test with real users who rely on assistive technologies.

For more on creating performant, accessible websites, check out our guides on web performance optimization and on-page SEO, which complement accessibility best practices.


Resources: