♿ Accessibility Best Practices
Create inclusive WordPress themes for everyone
Master WCAG guidelines and build accessible user experiences
Learning Objectives
- Understand WCAG 2.1 guidelines and principles
- Implement semantic HTML structure
- Use ARIA attributes appropriately
- Ensure keyboard navigation works properly
- Create accessible forms and navigation
- Implement proper color contrast
- Support screen readers effectively
- Test accessibility compliance
Why Accessibility Matters
Web accessibility ensures that people with disabilities can use your website. It's not just about compliance—it's about creating inclusive experiences that benefit everyone.
WCAG 2.1 Four Principles
Perceivable
Information must be presentable in ways users can perceive
Operable
Interface components must be operable by all users
Understandable
Information and UI operation must be understandable
Robust
Content must work with various assistive technologies
Semantic HTML Structure
Proper Document Structure
<!-- Good: Semantic HTML -->
<header role="banner">
<nav role="navigation" 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 role="main">
<article>
<h1>Page Title</h1>
<section>
<h2>Section Heading</h2>
<p>Content...</p>
</section>
</article>
<aside role="complementary">
<h2>Related Content</h2>
<!-- Sidebar content -->
</aside>
</main>
<footer role="contentinfo">
<p>© 2024 Site Name</p>
</footer>
<!-- Bad: Non-semantic HTML -->
<div class="header">
<div class="nav">
<div class="menu-item">Home</div>
<div class="menu-item">About</div>
</div>
</div>
✅ DO
- Use semantic HTML5 elements
- Maintain proper heading hierarchy
- Use lists for navigation
- Include landmark roles
- Provide skip links
❌ DON'T
- Use divs for everything
- Skip heading levels
- Use tables for layout
- Rely only on color
- Use placeholder as label
WordPress Accessibility Implementation
Accessible Navigation Menu
<?php
/**
* Accessible Navigation Walker
*/
class Accessible_Walker_Nav_Menu extends Walker_Nav_Menu {
function start_lvl( &$output, $depth = 0, $args = null ) {
$indent = str_repeat( "\t", $depth );
$output .= "\n$indent<ul class=\"sub-menu\" role=\"menu\" aria-label=\"Submenu\">\n";
}
function start_el( &$output, $item, $depth = 0, $args = null, $id = 0 ) {
$indent = ( $depth ) ? str_repeat( "\t", $depth ) : '';
$classes = empty( $item->classes ) ? array() : (array) $item->classes;
$classes[] = 'menu-item-' . $item->ID;
// Add aria-current for current page
$aria_current = '';
if ( in_array( 'current-menu-item', $classes ) ) {
$aria_current = ' aria-current="page"';
}
$class_names = join( ' ', apply_filters( 'nav_menu_css_class', array_filter( $classes ), $item, $args ) );
$class_names = $class_names ? ' class="' . esc_attr( $class_names ) . '"' : '';
$id = apply_filters( 'nav_menu_item_id', 'menu-item-'. $item->ID, $item, $args );
$id = $id ? ' id="' . esc_attr( $id ) . '"' : '';
$output .= $indent . '<li' . $id . $class_names . ' role="menuitem">';
$attributes = ! empty( $item->attr_title ) ? ' title="' . esc_attr( $item->attr_title ) .'"' : '';
$attributes .= ! empty( $item->target ) ? ' target="' . esc_attr( $item->target ) .'"' : '';
$attributes .= ! empty( $item->xfn ) ? ' rel="' . esc_attr( $item->xfn ) .'"' : '';
$attributes .= ! empty( $item->url ) ? ' href="' . esc_attr( $item->url ) .'"' : '';
$attributes .= $aria_current;
// Add dropdown toggle for parent items
if ( in_array( 'menu-item-has-children', $classes ) ) {
$attributes .= ' aria-haspopup="true" aria-expanded="false"';
}
$item_output = $args->before;
$item_output .= '<a'. $attributes .'>';
$item_output .= $args->link_before . apply_filters( 'the_title', $item->title, $item->ID ) . $args->link_after;
$item_output .= '</a>';
$item_output .= $args->after;
$output .= apply_filters( 'walker_nav_menu_start_el', $item_output, $item, $depth, $args );
}
}
// Usage
wp_nav_menu( array(
'theme_location' => 'primary',
'menu_class' => 'nav-menu',
'container' => 'nav',
'container_class' => 'main-navigation',
'container_aria_label' => 'Main navigation',
'walker' => new Accessible_Walker_Nav_Menu(),
) );
Skip Links
<?php
/**
* Add skip links
*/
function mytheme_skip_links() {
?>
<a class="screen-reader-text skip-link" href="#main">
<?php esc_html_e( 'Skip to content', 'mytheme' ); ?>
</a>
<a class="screen-reader-text skip-link" href="#primary-nav">
<?php esc_html_e( 'Skip to navigation', 'mytheme' ); ?>
</a>
<a class="screen-reader-text skip-link" href="#footer">
<?php esc_html_e( 'Skip to footer', 'mytheme' ); ?>
</a>
<?php
}
add_action( 'wp_body_open', 'mytheme_skip_links' );
// CSS for skip links
.screen-reader-text {
clip: rect(1px, 1px, 1px, 1px);
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
}
.screen-reader-text:focus {
clip: auto !important;
display: block;
height: auto;
left: 5px;
top: 5px;
width: auto;
z-index: 100000;
padding: 15px 20px;
background: #000;
color: #fff;
text-decoration: none;
box-shadow: 0 0 10px rgba(0,0,0,0.5);
}
ARIA Attributes
Common ARIA Attributes
<!-- Labels and Descriptions -->
<button aria-label="Close dialog">X</button>
<input type="search" aria-label="Search site" />
<div aria-labelledby="dialog-title">...</div>
<input aria-describedby="password-help" />
<span id="password-help">Must be at least 8 characters</span>
<!-- States -->
<button aria-pressed="false">Toggle</button>
<div aria-hidden="true">Decorative content</div>
<nav aria-expanded="false">Mobile menu</nav>
<li aria-current="page">Current page</li>
<!-- Relationships -->
<div role="tablist">
<button role="tab" aria-selected="true" aria-controls="panel1">Tab 1</button>
<button role="tab" aria-selected="false" aria-controls="panel2">Tab 2</button>
</div>
<div role="tabpanel" id="panel1" aria-labelledby="tab1">...</div>
<!-- Landmarks -->
<nav role="navigation" aria-label="Main">...</nav>
<aside role="complementary" aria-label="Related links">...</aside>
<div role="search">...</div>
Live Regions for Dynamic Content
<!-- Status messages -->
<div role="status" aria-live="polite" aria-atomic="true">
<p>Form saved successfully</p>
</div>
<!-- Error messages -->
<div role="alert" aria-live="assertive">
<p>Error: Invalid email address</p>
</div>
<!-- Loading states -->
<div aria-live="polite" aria-busy="true">
<span class="spinner"></span>
Loading content...
</div>
<!-- Search results -->
<div id="search-results" aria-live="polite" aria-relevant="additions removals">
<h2><span id="result-count">5</span> results found</h2>
<!-- Results list -->
</div>
<!-- JavaScript implementation -->
<script>
// Announce dynamic changes
function announceChange(message) {
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.className = 'screen-reader-text';
announcement.textContent = message;
document.body.appendChild(announcement);
setTimeout(() => {
document.body.removeChild(announcement);
}, 1000);
}
</script>
Accessible Widget Patterns
<?php
/**
* Accessible Modal Dialog
*/
function mytheme_modal_dialog() {
?>
<div id="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
tabindex="-1">
<div class="modal-content">
<h2 id="modal-title">Modal Title</h2>
<p id="modal-description">Modal description text</p>
<button type="button"
class="modal-close"
aria-label="Close modal">
×
</button>
<!-- Modal content -->
<div class="modal-actions">
<button type="button">Cancel</button>
<button type="button">Confirm</button>
</div>
</div>
</div>
<script>
// Trap focus within modal
function trapFocus(element) {
const focusableElements = element.querySelectorAll(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
element.addEventListener('keydown', function(e) {
if (e.key === 'Tab') {
if (e.shiftKey) { // Shift + Tab
if (document.activeElement === firstFocusable) {
lastFocusable.focus();
e.preventDefault();
}
} else { // Tab
if (document.activeElement === lastFocusable) {
firstFocusable.focus();
e.preventDefault();
}
}
}
if (e.key === 'Escape') {
closeModal();
}
});
}
</script>
<?php
}
Accessible Forms
Properly Labeled Form
<form role="form" aria-label="Contact form">
<!-- Explicit label association -->
<div class="form-group">
<label for="name">
Name <span aria-label="required">*</span>
</label>
<input type="text"
id="name"
name="name"
required
aria-required="true"
aria-describedby="name-error" />
<span id="name-error" role="alert" class="error"></span>
</div>
<!-- Grouped fields -->
<fieldset>
<legend>Preferred Contact Method</legend>
<input type="radio" id="contact-email" name="contact" value="email">
<label for="contact-email">Email</label>
<input type="radio" id="contact-phone" name="contact" value="phone">
<label for="contact-phone">Phone</label>
</fieldset>
<!-- Accessible error messages -->
<div class="form-group">
<label for="email">Email Address</label>
<input type="email"
id="email"
aria-invalid="true"
aria-describedby="email-error" />
<span id="email-error" role="alert">
Please enter a valid email address
</span>
</div>
<button type="submit">Submit Form</button>
</form>
Color Contrast Requirements
Good Contrast
Ratio: 7.5:1 ✅
Poor Contrast
Ratio: 1.5:1 ❌
| Element Type | WCAG AA | WCAG AAA |
|---|---|---|
| Normal Text | 4.5:1 | 7:1 |
| Large Text (18pt+) | 3:1 | 4.5:1 |
| UI Components | 3:1 | 3:1 |
Keyboard Navigation
Keyboard-Accessible JavaScript
// Accessible dropdown menu
class AccessibleMenu {
constructor(element) {
this.menu = element;
this.menuItems = element.querySelectorAll('[role="menuitem"]');
this.currentIndex = -1;
this.init();
}
init() {
// Add keyboard event listeners
this.menu.addEventListener('keydown', (e) => this.handleKeydown(e));
// Add focus management
this.menuItems.forEach((item, index) => {
item.setAttribute('tabindex', index === 0 ? '0' : '-1');
item.addEventListener('click', () => {
this.setFocus(index);
});
});
}
handleKeydown(e) {
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
this.setFocus(this.currentIndex + 1);
break;
case 'ArrowUp':
e.preventDefault();
this.setFocus(this.currentIndex - 1);
break;
case 'Home':
e.preventDefault();
this.setFocus(0);
break;
case 'End':
e.preventDefault();
this.setFocus(this.menuItems.length - 1);
break;
case 'Enter':
case ' ':
e.preventDefault();
this.menuItems[this.currentIndex].click();
break;
case 'Escape':
this.close();
break;
}
}
setFocus(index) {
// Wrap around
if (index < 0) index = this.menuItems.length - 1;
if (index >= this.menuItems.length) index = 0;
// Update tabindex
this.menuItems.forEach((item, i) => {
item.setAttribute('tabindex', i === index ? '0' : '-1');
});
// Set focus
this.menuItems[index].focus();
this.currentIndex = index;
}
close() {
this.menu.setAttribute('aria-expanded', 'false');
// Return focus to trigger element
}
}
Accessibility Testing
🔍 WAVE
WebAIM's evaluation tool for accessibility issues
🎯 axe DevTools
Browser extension for automated testing
📊 Lighthouse
Chrome DevTools accessibility audit
🖥️ NVDA
Free screen reader for Windows
🍎 VoiceOver
Built-in macOS/iOS screen reader
🎨 Contrast Checker
Tools for testing color contrast ratios
WordPress Theme Accessibility Checklist
Structure & Semantics
Navigation
Content
Forms
Accessibility Best Practices
Development Best Practices
- Design with accessibility in mind: Consider it from the start
- Use semantic HTML: Let HTML do the heavy lifting
- Test with real users: Include people with disabilities
- Provide alternatives: Multiple ways to access content
- Keep it simple: Complexity hurts accessibility
- Test early and often: Use automated and manual testing
- Document accessibility features: Help users find them
- Stay updated: WCAG guidelines evolve