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
- Understanding Web Accessibility
- Semantic HTML: The Foundation of Accessibility
- ARIA Attributes: Enhancing Accessibility
- Keyboard Navigation and Focus Management
- Screen Readers and Assistive Technologies
- WCAG Guidelines: The Standard for Accessibility
- Forms and Input Accessibility
- Images and Media Accessibility
- Color Contrast and Visual Accessibility
- Testing Accessibility
- Common Accessibility Mistakes
- Accessibility in Modern Frameworks
- Conclusion
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:
- Visual: Blindness, low vision, color blindness
- Auditory: Deafness, hard of hearing
- Motor: Limited fine motor control, paralysis
- 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>© 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
Modal Dialogs
<!-- ✅ 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 ordertabindex="-1": Element can receive focus programmatically but is excluded from tab ordertabindex="1+": Creates a custom tab order, which can confuse users—avoid this
Skip Links
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 modalsfunction 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 implementationfunction 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:
- Parse the DOM and accessibility tree
- Create a linear representation of content
- Announce content changes and user interactions
- 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:
- NVDA (Windows, free)
- JAWS (Windows, paid)
- VoiceOver (macOS/iOS, built-in)
- TalkBack (Android, built-in)
- 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 changesaria-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
Level AA (Recommended - Most Common Target)
- 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
# axe DevTools (browser extension)# Available for Chrome, Firefox, Edge
# WAVE (browser extension)# Web Accessibility Evaluation Tool
# Lighthouse (built into Chrome DevTools)# Run accessibility auditManual Testing Checklist
-
Keyboard Navigation
- Tab through all interactive elements
- Verify focus indicators are visible
- Test all functionality with keyboard only
- Check skip links work
-
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
-
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
-
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-coreimport axe from "axe-core";
// Run accessibility auditaxe.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 issuesBrowser DevTools
// Check accessibility tree in Chrome DevTools// Elements panel → Accessibility tab
// Check computed ARIA properties// Verify roles, labels, and statesReal 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;}Mistake 3: Generic Link Text
<!-- ❌ 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 Reactfunction Button({ children, onClick }) { return <button onClick={onClick}>{children}</button>;}
// ✅ Good: ARIA attributes in Reactfunction 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 labelsfunction 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 focusimport { 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
htmlForinstead offor, 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
- Start with semantic HTML: Use the right elements for the right purpose
- Enhance with ARIA when needed: Don’t use ARIA when HTML can do the job
- Ensure keyboard accessibility: All functionality must work without a mouse
- Test thoroughly: Combine automated tools with manual and user testing
- Follow WCAG guidelines: Aim for Level AA compliance as a minimum
- 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: