Accessibility (a11y)
Comprehensive accessibility guidelines based on WCAG 2.1 and Lighthouse accessibility audits. Goal: make content usable by everyone, including people with disabilities.
WCAG Principles: POUR
Principle Description P erceivableContent can be perceived through different senses O perableInterface can be operated by all users U nderstandableContent and interface are understandable R obustContent works with assistive technologies
Level Requirement Target A Minimum accessibility Must pass AA Standard compliance Should pass (legal requirement in many jurisdictions) AAA Enhanced accessibility Nice to have
Perceivable
Text Alternatives (1.1)
Images
Complex Images
Icon Buttons
<!-- ❌ Missing alt -->
< img src = "chart.png" >
<!-- ✅ Descriptive alt -->
< img src = "chart.png" alt = "Bar chart showing 40% increase in Q3 sales" >
<!-- ✅ Decorative image (empty alt) -->
< img src = "decorative-border.png" alt = "" role = "presentation" >
<!-- ✅ Complex image with longer description -->
< figure >
< img src = "infographic.png" alt = "2024 market trends infographic"
aria-describedby = "infographic-desc" >
< figcaption id = "infographic-desc" >
<!-- Detailed description -->
</ figcaption >
</ figure >
<!-- ❌ No accessible name -->
< button >< svg > <!-- menu icon --> </ svg ></ button >
<!-- ✅ Using aria-label -->
< button aria-label = "Open menu" >
< svg aria-hidden = "true" > <!-- menu icon --> </ svg >
</ button >
<!-- ✅ Using visually hidden text -->
< button >
< svg aria-hidden = "true" > <!-- menu icon --> </ svg >
< span class = "visually-hidden" > Open menu </ span >
</ button >
Visually Hidden Class
.visually-hidden {
position : absolute ;
width : 1 px ;
height : 1 px ;
padding : 0 ;
margin : -1 px ;
overflow : hidden ;
clip : rect ( 0 , 0 , 0 , 0 );
white-space : nowrap ;
border : 0 ;
}
Color Contrast (1.4.3, 1.4.6)
Text Size AA Minimum AAA Enhanced Normal text (< 18px / < 14px bold) 4.5:1 7:1 Large text (≥ 18px / ≥ 14px bold) 3:1 4.5:1 UI components & graphics 3:1 3:1
❌ Low Contrast (2.5:1)
✅ Sufficient Contrast (7:1)
.low-contrast {
color : #999 ;
background : #fff ;
}
Don’t rely on color alone to convey information. Use color + icon + text.
<!-- ❌ Only color indicates error -->
< input class = "error-border" >
< style > .error-border { border-color : red ; } </ style >
<!-- ✅ Color + icon + text -->
< div class = "field-error" >
< input aria-invalid = "true" aria-describedby = "email-error" >
< span id = "email-error" class = "error-message" >
< svg aria-hidden = "true" > <!-- error icon --> </ svg >
Please enter a valid email address
</ span >
</ div >
<!-- Video with captions -->
< video controls >
< source src = "video.mp4" type = "video/mp4" >
< track kind = "captions" src = "captions.vtt" srclang = "en" label = "English" default >
< track kind = "descriptions" src = "descriptions.vtt" srclang = "en" label = "Descriptions" >
</ video >
<!-- Audio with transcript -->
< audio controls >
< source src = "podcast.mp3" type = "audio/mp3" >
</ audio >
< details >
< summary > Transcript </ summary >
< p > Full transcript text... </ p >
</ details >
Operable
Keyboard Accessible (2.1)
❌ Only Handles Click
✅ Handles Click and Keyboard
element . addEventListener ( 'click' , handleAction );
No Keyboard Traps
// Modal focus management
function openModal ( modal ) {
const focusableElements = modal . querySelectorAll (
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements [ 0 ];
const lastElement = focusableElements [ focusableElements . length - 1 ];
// Trap focus within modal
modal . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Tab' ) {
if ( e . shiftKey && document . activeElement === firstElement ) {
e . preventDefault ();
lastElement . focus ();
} else if ( ! e . shiftKey && document . activeElement === lastElement ) {
e . preventDefault ();
firstElement . focus ();
}
}
if ( e . key === 'Escape' ) {
closeModal ();
}
});
firstElement . focus ();
}
Focus Visible (2.4.7)
/* ❌ Never remove focus outlines */
* :focus { outline : none ; }
/* ✅ Use :focus-visible for keyboard-only focus */
:focus {
outline : none ;
}
:focus-visible {
outline : 2 px solid #005fcc ;
outline-offset : 2 px ;
}
/* ✅ Or custom focus styles */
button :focus-visible {
box-shadow : 0 0 0 3 px rgba ( 0 , 95 , 204 , 0.5 );
}
Skip Links (2.4.1)
< body >
< a href = "#main-content" class = "skip-link" > Skip to main content </ a >
< header > <!-- navigation --> </ header >
< main id = "main-content" tabindex = "-1" >
<!-- main content -->
</ main >
</ body >
.skip-link {
position : absolute ;
top : -40 px ;
left : 0 ;
background : #000 ;
color : #fff ;
padding : 8 px 16 px ;
z-index : 100 ;
}
.skip-link:focus {
top : 0 ;
}
Motion (2.3)
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
* ,
* ::before ,
* ::after {
animation-duration : 0.01 ms !important ;
animation-iteration-count : 1 !important ;
transition-duration : 0.01 ms !important ;
scroll-behavior : auto !important ;
}
}
Understandable
Page Language (3.1.1)
<!-- ❌ No language specified -->
< html >
<!-- ✅ Language specified -->
< html lang = "en" >
<!-- ✅ Language changes within page -->
< p > The French word for hello is < span lang = "fr" > bonjour </ span > . </ p >
Consistent Navigation (3.2.3)
<!-- Navigation should be consistent across pages -->
< nav aria-label = "Main" >
< 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 >
Explicit Label
Implicit Label
With Instructions
<!-- ❌ No label association -->
< input type = "email" placeholder = "Email" >
<!-- ✅ Explicit label -->
< label for = "email" > Email address </ label >
< input type = "email" id = "email" name = "email"
autocomplete = "email" required >
<!-- ✅ Implicit label -->
< label >
Email address
< input type = "email" name = "email" autocomplete = "email" required >
</ label >
<!-- ✅ With instructions -->
< label for = "password" > Password </ label >
< input type = "password" id = "password"
aria-describedby = "password-requirements" >
< p id = "password-requirements" >
Must be at least 8 characters with one number.
</ p >
Error Handling (3.3.1, 3.3.3)
<!-- Announce errors to screen readers -->
< form novalidate >
< div class = "field" aria-live = "polite" >
< label for = "email" > Email </ label >
< input type = "email" id = "email"
aria-invalid = "true"
aria-describedby = "email-error" >
< p id = "email-error" class = "error" role = "alert" >
Please enter a valid email address (e.g., [email protected] )
</ p >
</ div >
</ form >
// Focus first error on submit
form . addEventListener ( 'submit' , ( e ) => {
const firstError = form . querySelector ( '[aria-invalid="true"]' );
if ( firstError ) {
e . preventDefault ();
firstError . focus ();
// Announce error summary
const errorSummary = document . getElementById ( 'error-summary' );
errorSummary . textContent = ` ${ errors . length } errors found. Please fix them and try again.` ;
errorSummary . focus ();
}
});
Robust
Valid HTML (4.1.1)
<!-- Duplicate IDs -->
< div id = "content" > ... </ div >
< div id = "content" > ... </ div >
<!-- Invalid nesting -->
< a href = "/" >< button > Click </ button ></ a >
ARIA Usage (4.1.2)
Prefer native elements over ARIA roles whenever possible.
❌ Avoid
✅ Native
When ARIA Needed
<!-- ARIA role on div -->
< div role = "button" tabindex = "0" > Click me </ div >
<!-- ARIA checkbox -->
< div role = "checkbox" aria-checked = "false" > Option </ div >
<!-- Native button -->
< button > Click me </ button >
<!-- Native checkbox -->
< label >< input type = "checkbox" > Option </ label >
<!-- Custom tabs component -->
< div role = "tablist" aria-label = "Product information" >
< button role = "tab" id = "tab-1" aria-selected = "true"
aria-controls = "panel-1" > Description </ button >
< button role = "tab" id = "tab-2" aria-selected = "false"
aria-controls = "panel-2" tabindex = "-1" > Reviews </ button >
</ div >
< div role = "tabpanel" id = "panel-1" aria-labelledby = "tab-1" >
<!-- Panel content -->
</ div >
< div role = "tabpanel" id = "panel-2" aria-labelledby = "tab-2" hidden >
<!-- Panel content -->
</ div >
Live Regions (4.1.3)
<!-- Status updates -->
< div aria-live = "polite" aria-atomic = "true" class = "status" >
<!-- Content updates announced to screen readers -->
</ div >
<!-- Urgent alerts -->
< div role = "alert" aria-live = "assertive" >
<!-- Interrupts current announcement -->
</ div >
// Announce dynamic content changes
function showNotification ( message , type = 'polite' ) {
const container = document . getElementById ( ` ${ type } -announcer` );
container . textContent = '' ; // Clear first
requestAnimationFrame (() => {
container . textContent = message ;
});
}
Testing Checklist
Automated Testing
# Lighthouse accessibility audit
npx lighthouse https://example.com --only-categories=accessibility
# axe-core
npm install @axe-core/cli -g
axe https://example.com
Manual Testing
Screen Reader Commands
Action VoiceOver (Mac) NVDA (Windows) Start/Stop ⌘ + F5 Ctrl + Alt + N Next item VO + → ↓ Previous item VO + ← ↑ Activate VO + Space Enter Headings list VO + U, then arrows H / Shift + H Links list VO + U K / Shift + K
Common Issues by Impact
Missing form labels
Missing image alt text
Insufficient color contrast
Keyboard traps
No focus indicators
Serious (Fix Before Launch)
Missing page language
Missing heading structure
Non-descriptive link text
Auto-playing media
Missing skip links
Moderate (Fix Soon)
Missing ARIA labels on icons
Inconsistent navigation
Missing error identification
Timing without controls
Missing landmark regions
External Resources
Web Quality Audit For comprehensive accessibility audits as part of overall web quality