Accessibility isn’t optional—it’s essential for reaching all users and often legally required. Browser extensions present unique accessibility challenges because they inject UI into websites and create new interaction patterns. This guide covers how to build extensions that work for everyone.
Why Extension Accessibility Matters
Extensions interact with users in several contexts:
- Popup UI: Triggered by toolbar icon
- Options pages: Full-page settings
- Content scripts: UI injected into websites
- Keyboard shortcuts: Non-visual interaction
Each context has different accessibility requirements and challenges.
- 15-20% of users have some form of disability
- Temporary impairments (broken arm, bright sunlight) affect everyone
- Accessible extensions are easier to use for all users
- Required for enterprise/government contracts (Section 508)
WCAG Compliance for Extensions
Follow WCAG 2.1 guidelines at minimum AA level:
Perceivable
All content must be presentable in ways users can perceive.
<!-- Good: Image with alt text -->
<button>
<img src="icon.png" alt="Settings">
</button>
<!-- Better: SVG with accessible name -->
<button aria-label="Settings">
<svg aria-hidden="true">...</svg>
</button>
<!-- Good: Color is not the only indicator -->
<div class="status">
<span class="status-dot status-success" aria-hidden="true"></span>
<span>Connected successfully</span>
</div>
<!-- Bad: Color alone indicates status -->
<div class="status" style="color: green;">●</div>
Operable
All functionality must be keyboard accessible.
// Keyboard-accessible popup menu
class AccessibleMenu {
constructor(container) {
this.container = container;
this.items = container.querySelectorAll('[role="menuitem"]');
this.currentIndex = 0;
this.bindEvents();
}
bindEvents() {
this.container.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.focusNext();
break;
case 'ArrowUp':
e.preventDefault();
this.focusPrevious();
break;
case 'Home':
e.preventDefault();
this.focusFirst();
break;
case 'End':
e.preventDefault();
this.focusLast();
break;
case 'Enter':
case ' ':
e.preventDefault();
this.activateCurrent();
break;
case 'Escape':
this.close();
break;
}
});
}
focusNext() {
this.currentIndex = (this.currentIndex + 1) % this.items.length;
this.items[this.currentIndex].focus();
}
focusPrevious() {
this.currentIndex = (this.currentIndex - 1 + this.items.length) % this.items.length;
this.items[this.currentIndex].focus();
}
focusFirst() {
this.currentIndex = 0;
this.items[0].focus();
}
focusLast() {
this.currentIndex = this.items.length - 1;
this.items[this.currentIndex].focus();
}
activateCurrent() {
this.items[this.currentIndex].click();
}
}
Understandable
Content should be readable and interface behavior predictable.
<!-- Clear form labels -->
<div class="form-group">
<label for="api-key">API Key</label>
<input
type="password"
id="api-key"
aria-describedby="api-key-hint api-key-error"
>
<p id="api-key-hint" class="hint">
Find your API key in your account settings
</p>
<p id="api-key-error" class="error" role="alert" hidden>
Invalid API key format
</p>
</div>
Robust
Content must work with assistive technologies.
<!-- Use semantic HTML and ARIA when needed -->
<div role="tablist" aria-label="Extension settings">
<button
role="tab"
id="general-tab"
aria-controls="general-panel"
aria-selected="true"
>
General
</button>
<button
role="tab"
id="advanced-tab"
aria-controls="advanced-panel"
aria-selected="false"
tabindex="-1"
>
Advanced
</button>
</div>
<div
role="tabpanel"
id="general-panel"
aria-labelledby="general-tab"
>
<!-- General settings content -->
</div>
<div
role="tabpanel"
id="advanced-panel"
aria-labelledby="advanced-tab"
hidden
>
<!-- Advanced settings content -->
</div>
Screen Reader Support
Test with actual screen readers (NVDA, VoiceOver, JAWS):
// Announce dynamic changes
function announceToScreenReader(message, priority = 'polite') {
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', priority);
announcement.setAttribute('aria-atomic', 'true');
announcement.className = 'sr-only';
announcement.textContent = message;
document.body.appendChild(announcement);
// Remove after announcement
setTimeout(() => announcement.remove(), 1000);
}
// Usage
async function saveSettings() {
try {
await chrome.storage.sync.set(settings);
announceToScreenReader('Settings saved successfully');
} catch (error) {
announceToScreenReader('Error saving settings', 'assertive');
}
}
/* Visually hidden but accessible to screen readers */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
- NVDA (Windows, free): Most popular
- VoiceOver (macOS/iOS, built-in): Test Safari
- JAWS (Windows, paid): Enterprise standard
- ChromeVox (Chrome extension): Quick testing
Focus Management
Proper focus management is critical for keyboard users:
// Trap focus within popup/modal
class FocusTrap {
constructor(element) {
this.element = element;
this.focusableElements = this.getFocusableElements();
this.firstElement = this.focusableElements[0];
this.lastElement = this.focusableElements[this.focusableElements.length - 1];
this.previouslyFocused = document.activeElement;
}
getFocusableElements() {
return this.element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
}
activate() {
this.element.addEventListener('keydown', this.handleKeydown.bind(this));
this.firstElement?.focus();
}
deactivate() {
this.element.removeEventListener('keydown', this.handleKeydown.bind(this));
this.previouslyFocused?.focus();
}
handleKeydown(e) {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === this.firstElement) {
e.preventDefault();
this.lastElement.focus();
}
} else {
if (document.activeElement === this.lastElement) {
e.preventDefault();
this.firstElement.focus();
}
}
}
}
// Usage for extension popup
const popup = document.getElementById('popup-container');
const trap = new FocusTrap(popup);
trap.activate();
Content Script Accessibility
Injected UI must not break the page’s accessibility:
// Create accessible injected UI
function createAccessibleOverlay() {
const overlay = document.createElement('div');
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'ext-overlay-title');
overlay.innerHTML = `
<div class="ext-overlay-content">
<h2 id="ext-overlay-title">Extension Panel</h2>
<button class="ext-close" aria-label="Close panel">×</button>
<div class="ext-body">
<!-- Content -->
</div>
</div>
`;
// Isolate styles to prevent conflicts
overlay.attachShadow({ mode: 'open' });
// Set up focus trap
const trap = new FocusTrap(overlay);
// Handle close
const closeBtn = overlay.querySelector('.ext-close');
closeBtn.addEventListener('click', () => {
trap.deactivate();
overlay.remove();
});
document.body.appendChild(overlay);
trap.activate();
return overlay;
}
Color and Contrast
Ensure sufficient contrast and don’t rely on color alone:
/* Minimum contrast ratios (WCAG AA) */
:root {
/* Normal text: 4.5:1 minimum */
--text-primary: #1a1a1a; /* on white: 16.1:1 */
--text-secondary: #5a5a5a; /* on white: 7.5:1 */
/* Large text (18px+): 3:1 minimum */
--heading-color: #2d2d2d; /* on white: 12.6:1 */
/* Interactive elements: 3:1 against adjacent colors */
--button-bg: #0066cc;
--button-focus: #004499; /* Darker for focus state */
}
/* Focus indicators must be visible */
:focus {
outline: 2px solid var(--button-focus);
outline-offset: 2px;
}
/* Don't remove focus outlines */
:focus:not(:focus-visible) {
outline: none; /* OK: Only hide for mouse users */
}
/* Error states: Don't rely on red color alone */
.error {
color: #c00;
border-left: 4px solid #c00;
padding-left: 8px;
}
.error::before {
content: '⚠ '; /* Visual indicator beyond color */
}
Reduced Motion
Respect user preferences for reduced motion:
/* Default: Allow animations */
.fade-in {
animation: fadeIn 0.3s ease-out;
}
/* Reduce motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
.fade-in {
animation: none;
opacity: 1;
}
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
// Check motion preference in JavaScript
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function animateElement(element) {
if (prefersReducedMotion) {
element.style.opacity = '1';
return;
}
element.animate([
{ opacity: 0 },
{ opacity: 1 }
], { duration: 300 });
}
Testing Accessibility
Automated Testing
// Use axe-core for automated testing
const { AxePuppeteer } = require('@axe-core/puppeteer');
const puppeteer = require('puppeteer');
async function testPopupAccessibility() {
const browser = await puppeteer.launch({
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`
]
});
const page = await browser.newPage();
await page.goto(`chrome-extension://${extensionId}/popup.html`);
const results = await new AxePuppeteer(page).analyze();
console.log('Violations:', results.violations);
console.log('Passes:', results.passes.length);
await browser.close();
// Fail if critical violations
if (results.violations.some(v => v.impact === 'critical')) {
throw new Error('Critical accessibility violations found');
}
}
Manual Testing Checklist
## Keyboard Navigation
- [ ] Can access all features with keyboard only
- [ ] Tab order is logical
- [ ] Focus is visible at all times
- [ ] No keyboard traps
## Screen Reader
- [ ] All images have alt text
- [ ] Form inputs have labels
- [ ] Buttons have accessible names
- [ ] Dynamic content is announced
## Visual
- [ ] Color contrast meets 4.5:1 (text) / 3:1 (large text)
- [ ] Information not conveyed by color alone
- [ ] Text resizes without breaking layout
- [ ] Works with high contrast mode
## Interaction
- [ ] Click targets are at least 44x44px
- [ ] Error messages are clear and helpful
- [ ] Time limits can be extended
- [ ] No flashing content (> 3 flashes/second)
Accessibility Documentation
Document accessibility features for users:
# Accessibility Features
## Keyboard Shortcuts
- `Ctrl+Shift+E`: Open extension popup
- `Tab`: Navigate between elements
- `Enter/Space`: Activate buttons
- `Escape`: Close popups
## Screen Reader Support
This extension is compatible with:
- NVDA
- VoiceOver
- JAWS
- ChromeVox
## Customization
- Supports system dark mode
- Respects reduced motion preferences
- Works with browser zoom up to 400%
## Reporting Issues
Found an accessibility issue? Contact us at accessibility@extension.com
Summary
Building accessible extensions benefits all users and expands your potential audience. Focus on keyboard navigation, screen reader compatibility, and sufficient color contrast. Test with actual assistive technologies, not just automated tools.
Key accessibility requirements:
- Full keyboard navigation
- Screen reader announcements
- Visible focus indicators
- Sufficient color contrast (4.5:1 minimum)
- Respect motion preferences
- Clear focus management
- Accessible injected content