Accessibility A11y Inclusive Design WCAG

Building Accessible and Inclusive Browser Extensions

E
Extendable Team
· 13 min read

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.

Accessibility Impact:
  • 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;
}
Screen Reader Testing:
  • 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