Building Accessible Chrome Extensions

29 min read

Building Accessible Chrome Extensions

Accessibility is not just a best practice—it’s a legal requirement in many jurisdictions and a moral imperative to ensure your extension serves all users, including those with disabilities. Chrome extensions present unique accessibility challenges because they encompass multiple UI contexts: popups, options pages, side panels, and content scripts. This guide covers essential accessibility patterns and WCAG compliance strategies specifically for Chrome extension development.

Understanding Extension Accessibility Contexts

Chrome extensions have several distinct UI surfaces, each with its own accessibility considerations. The popup appears when users click the extension icon, the options page provides configuration settings, the side panel offers a persistent sidebar experience, and content scripts inject UI into web pages. Each context requires different accessibility approaches, but all share fundamental principles of perceivable, operable, understandable, and robust design.

Extension Manifest Requirements

Your extension’s manifest should declare appropriate permissions and configurations that support accessibility:

{
  "manifest_version": 3,
  "name": "Accessible Extension",
  "version": "1.0",
  "description": "An accessible Chrome extension example",
  "permissions": ["storage"],
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "images/icon16.png",
      "48": "images/icon48.png",
      "128": "images/icon128.png"
    }
  },
  "options_page": "options.html",
  "side_panel": {
    "default_path": "sidepanel.html"
  }
}

ARIA Attributes in Extension UIs

Accessible Rich Internet Applications (ARIA) attributes provide semantic information to assistive technologies. Proper ARIA usage is crucial for making complex extension interfaces understandable to screen readers.

ARIA in Popups

The popup is often the primary interface for your extension. Ensure all interactive elements have appropriate ARIA labels:

<!-- popup.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Quick Actions</title>
  <link rel="stylesheet" href="popup.css">
</head>
<body>
  <main role="main" aria-labelledby="popup-title">
    <h1 id="popup-title" class="sr-only">Quick Actions Extension</h1>
    
    <!-- Accessible button with aria-label -->
    <button 
      id="bookmark-btn"
      type="button"
      aria-label="Bookmark this page"
      aria-describedby="bookmark-desc">
      <span aria-hidden="true"></span>
      <span id="bookmark-desc" class="sr-only">Add current page to bookmarks</span>
    </button>

    <!-- Accessible toggle switch -->
    <div 
      role="switch" 
      id="dark-mode-toggle"
      aria-checked="false"
      aria-label="Enable dark mode"
      tabindex="0"
      role="button">
      <span>Dark Mode</span>
      <span class="toggle-indicator" aria-hidden="true"></span>
    </div>

    <!-- Accessible list with aria-describedby -->
    <ul 
      role="list" 
      aria-label="Recent bookmarks"
      aria-describedby="list-instructions">
      <li role="listitem">
        <a href="#" aria-label="Bookmark: Chrome Extensions Guide">
          Chrome Extensions Guide
        </a>
      </li>
    </ul>
    <p id="list-instructions" class="sr-only">
      Use arrow keys to navigate between bookmarks
    </p>
  </main>
  <script src="popup.js"></script>
</body>
</html>

ARIA in Options Pages

Options pages often contain complex forms and settings. Use fieldset and legend for grouped controls:

<!-- options.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Extension Settings</title>
</head>
<body>
  <main>
    <h1>Settings</h1>
    
    <form id="settings-form">
      <!-- Grouped settings with fieldset -->
      <fieldset>
        <legend>Notification Preferences</legend>
        
        <div role="group" aria-labelledby="notification-types">
          <h2 id="notification-types" class="sr-only">Notification Types</h2>
          
          <label>
            <input type="checkbox" name="notify_bookmarks">
            Bookmark notifications
          </label>
          
          <label>
            <input type="checkbox" name="notify_updates">
            Update notifications
          </label>
        </div>
        
        <div role="radiogroup" aria-labelledby="frequency-label">
          <h2 id="frequency-label">Notification Frequency</h2>
          
          <label>
            <input type="radio" name="frequency" value="immediate">
            Immediate
          </label>
          
          <label>
            <input type="radio" name="frequency" value="daily">
            Daily digest
          </label>
          
          <label>
            <input type="radio" name="frequency" value="weekly">
            Weekly summary
          </label>
        </div>
      </fieldset>

      <!-- Live region for dynamic updates -->
      <div 
        role="status" 
        aria-live="polite" 
        aria-atomic="true"
        id="save-status">
      </div>
      
      <button type="submit">Save Settings</button>
    </form>
  </main>
</body>
</html>

ARIA in Side Panels

Side panels can contain rich interactive content. Ensure proper navigation patterns:

<!-- sidepanel.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Side Panel</title>
</head>
<body>
  <div class="app-container">
    <!-- Skip link for keyboard users -->
    <a href="#main-content" class="skip-link">Skip to main content</a>
    
    <nav aria-label="Side panel navigation">
      <ul role="tablist" aria-label="Panel sections">
        <li role="presentation">
          <button 
            role="tab" 
            aria-selected="true" 
            aria-controls="tabpanel-home"
            id="tab-home"
            tabindex="0">
            Home
          </button>
        </li>
        <li role="presentation">
          <button 
            role="tab" 
            aria-selected="false" 
            aria-controls="tabpanel-settings"
            id="tab-settings"
            tabindex="-1">
            Settings
          </button>
        </li>
      </ul>
    </nav>
    
    <div role="tabpanel" id="tabpanel-home" aria-labelledby="tab-home">
      <h1 id="main-content">Dashboard</h1>
      <!-- Tab panel content -->
    </div>
    
    <div role="tabpanel" id="tabpanel-settings" aria-labelledby="tab-settings" hidden>
      <!-- Settings content -->
    </div>
  </div>
</body>
</html>

Keyboard Navigation

Keyboard accessibility is fundamental for users who cannot use a mouse. All functionality must be accessible via keyboard alone.

Implementing Focus Management

Proper focus management ensures users can navigate logically through your interface:

// popup.js - Proper focus management
document.addEventListener('DOMContentLoaded', () => {
  const toggle = document.getElementById('dark-mode-toggle');
  const form = document.getElementById('settings-form');
  const statusRegion = document.getElementById('save-status');
  
  // Make toggle keyboard accessible
  toggle.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      toggle.click();
    }
  });
  
  toggle.addEventListener('click', () => {
    const isChecked = toggle.getAttribute('aria-checked') === 'true';
    toggle.setAttribute('aria-checked', !isChecked);
    
    // Announce state change to screen readers
    announceToScreenReader(
      `Dark mode ${isChecked ? 'disabled' : 'enabled'}`
    );
  });
  
  // Form submission handling
  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    
    try {
      await saveSettings();
      
      // Focus on status region and announce success
      statusRegion.textContent = 'Settings saved successfully';
      statusRegion.focus();
      
      // Clear status after delay
      setTimeout(() => {
        statusRegion.textContent = '';
      }, 3000);
    } catch (error) {
      statusRegion.setAttribute('role', 'alert');
      statusRegion.textContent = 'Error saving settings. Please try again.';
    }
  });
});

// Helper function for screen reader announcements
function announceToScreenReader(message) {
  const announcement = document.createElement('div');
  announcement.setAttribute('role', 'status');
  announcement.setAttribute('aria-live', 'polite');
  announcement.setAttribute('aria-atomic', 'true');
  announcement.className = 'sr-only';
  announcement.textContent = message;
  document.body.appendChild(announcement);
  
  setTimeout(() => announcement.remove(), 1000);
}

Keyboard Shortcuts for Extensions

Chrome’s commands API enables keyboard shortcuts while respecting system accessibility settings:

{
  "commands": {
    "toggle-feature": {
      "suggested_key": {
        "default": "Ctrl+Shift+Y",
        "mac": "Command+Shift+Y"
      },
      "description": "Toggle feature on/off"
    },
    "open-settings": {
      "suggested_key": {
        "default": "Ctrl+Shift+S",
        "mac": "Command+Shift+S"
      },
      "description": "Open extension settings"
    }
  }
}
// background.js - Handle keyboard commands
chrome.commands.onCommand.addListener((command) => {
  switch (command) {
    case 'toggle-feature':
      toggleFeature();
      break;
    case 'open-settings':
      chrome.runtime.openOptionsPage();
      break;
  }
});

async function toggleFeature() {
  const tab = await chrome.tabs.query({ active: true, currentWindow: true });
  
  chrome.tabs.sendMessage(tab[0].id, { 
    action: 'toggleFeature' 
  }, (response) => {
    if (chrome.runtime.lastError) {
      // Handle case where content script isn't loaded
      console.log('Content script not available');
    }
  });
}

Screen Reader Compatibility

Screen readers like NVDA, JAWS, and VoiceOver require specific HTML patterns and ARIA roles to properly interpret your UI.

Semantic HTML Fundamentals

Always prefer semantic HTML elements over generic divs:

<!-- Good: Semantic HTML -->
<header>
  <h1>Extension Title</h1>
  <nav aria-label="Main navigation">
    <ul>
      <li><a href="#home">Home</a></li>
      <li><a href="#settings">Settings</a></li>
    </ul>
  </nav>
</header>

<main>
  <article>
    <h2>Article Title</h2>
    <p>Article content...</p>
  </article>
  
  <aside aria-label="Related information">
    <h3>Related</h3>
  </aside>
</main>

<footer>
  <p>&copy; 2024 Extension</p>
</footer>

<!-- Avoid: Non-semantic markup -->
<div class="header">
  <div class="title">Extension Title</div>
  <div class="nav">
    <div class="link">Home</div>
  </div>
</div>

Managing Focus in Dynamic Content

When content loads dynamically, manage focus appropriately:

// Handle dynamic content loading with focus management
async function loadBookmarks() {
  const container = document.getElementById('bookmarks-container');
  const loading = document.getElementById('loading-indicator');
  
  // Show loading state
  loading.setAttribute('role', 'status');
  loading.setAttribute('aria-live', 'polite');
  loading.textContent = 'Loading bookmarks...';
  
  try {
    const bookmarks = await fetchBookmarks();
    
    // Clear loading indicator
    loading.textContent = '';
    
    // Build bookmark list
    container.innerHTML = '';
    
    if (bookmarks.length === 0) {
      container.innerHTML = '<p>No bookmarks found</p>';
      return;
    }
    
    const list = document.createElement('ul');
    list.setAttribute('role', 'list');
    list.setAttribute('aria-label', 'Your bookmarks');
    
    bookmarks.forEach((bookmark, index) => {
      const item = document.createElement('li');
      item.setAttribute('role', 'listitem');
      
      const link = document.createElement('a');
      link.href = bookmark.url;
      link.textContent = bookmark.title;
      link.setAttribute('aria-label', 
        `Bookmark ${index + 1} of ${bookmarks.length}: ${bookmark.title}`
      );
      
      item.appendChild(link);
      list.appendChild(item);
    });
    
    container.appendChild(list);
    
    // Focus on first item for screen reader users
    const firstLink = list.querySelector('a');
    if (firstLink) {
      firstLink.focus();
    }
    
  } catch (error) {
    loading.setAttribute('role', 'alert');
    loading.textContent = 'Failed to load bookmarks. Please try again.';
  }
}

Color Contrast and Visual Design

WCAG requires sufficient color contrast to ensure text is readable for users with visual impairments.

Meeting WCAG Contrast Requirements

Target these contrast ratios:

/* styles.css - Accessible color scheme */

/* WCAG AA: 4.5:1 for normal text, 3:1 for large text */
:root {
  /* High contrast colors */
  --text-primary: #1a1a1a;
  --text-secondary: #4a4a4a;
  --background-primary: #ffffff;
  --background-secondary: #f5f5f5;
  --accent-color: #0066cc;
  --accent-hover: #004999;
  --error-color: #cc0000;
  --success-color: #007500;
  --warning-color: #995500;
  
  /* Focus indicator - high visibility */
  --focus-outline: 3px solid #0066cc;
  --focus-offset: 2px;
}

/* Dark mode with sufficient contrast */
[data-theme="dark"] {
  --text-primary: #f0f0f0;
  --text-secondary: #c0c0c0;
  --background-primary: #1a1a1a;
  --background-secondary: #2d2d2d;
  --accent-color: #66b3ff;
  --accent-hover: #99ccff;
  --error-color: #ff6666;
  --success-color: #66cc66;
  --warning-color: #ffcc66;
}

/* Ensure links have visual distinction */
a {
  color: var(--accent-color);
  text-decoration: underline;
}

a:hover {
  color: var(--accent-hover);
}

/* Focus styles - critical for keyboard navigation */
*:focus {
  outline: var(--focus-outline);
  outline-offset: var(--focus-offset);
}

*:focus:not(:focus-visible) {
  outline: none;
}

*:focus-visible {
  outline: var(--focus-outline);
  outline-offset: var(--focus-offset);
}

Testing Color Contrast

// contrast-checker.js - Simple contrast ratio calculator

function getLuminance(hexColor) {
  const rgb = hexToRgb(hexColor);
  const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((c) => {
    c = c / 255;
    return c <= 0.03928
      ? c / 12.92
      : Math.pow((c + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

function hexToRgb(hex) {
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  return result
    ? {
        r: parseInt(result[1], 16),
        g: parseInt(result[2], 16),
        b: parseInt(result[3], 16),
      }
    : null;
}

function calculateContrastRatio(color1, color2) {
  const l1 = getLuminance(color1);
  const l2 = getLuminance(color2);
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}

// WCAG thresholds
const WCAG = {
  AA_NORMAL_TEXT: 4.5,
  AA_LARGE_TEXT: 3,
  AAA_NORMAL_TEXT: 7,
  AAA_LARGE_TEXT: 4.5,
};

function checkContrast(foreground, background) {
  const ratio = calculateContrastRatio(foreground, background);
  return {
    ratio: ratio.toFixed(2),
    aaNormal: ratio >= WCAG.AA_NORMAL_TEXT,
    aaLarge: ratio >= WCAG.AA_LARGE_TEXT,
    aaaNormal: ratio >= WCAG.AAA_NORMAL_TEXT,
    aaaLarge: ratio >= WCAG.AAA_LARGE_TEXT,
  };
}

// Example usage
const result = checkContrast('#1a1a1a', '#ffffff');
console.log(`Contrast ratio: ${result.ratio}:1`);
console.log(`WCAG AA Normal Text: ${result.aaNormal ? 'PASS' : 'FAIL'}`);

High Contrast Mode Support

High contrast mode is essential for users with low vision. Detect and adapt to system preferences:

// detect-high-contrast.js

function detectHighContrast() {
  // Check for high contrast mode
  const isHighContrast = window.matchMedia(
    '(forced-colors: active), (prefers-contrast: more)'
  ).matches;
  
  // Check for reduced motion preference
  const prefersReducedMotion = window.matchMedia(
    '(prefers-reduced-motion: reduce)'
  ).matches;
  
  return { isHighContrast, prefersReducedMotion };
}

// Apply appropriate styles based on user preferences
function applyAccessibleStyles() {
  const { isHighContrast, prefersReducedMotion } = detectHighContrast();
  
  if (isHighContrast) {
    document.body.classList.add('high-contrast');
  }
  
  if (prefersReducedMotion) {
    document.body.classList.add('reduced-motion');
  }
}

// Listen for preference changes
window.matchMedia('(prefers-contrast: more)').addEventListener('change', () => {
  applyAccessibleStyles();
});

window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => {
  applyAccessibleStyles();
});
/* high-contrast.css - High contrast mode styles */
@media (forced-colors: active) {
  /* Use system colors that work in high contrast */
  button {
    background: ButtonFace;
    border: 2px solid ButtonText;
    color: ButtonText;
  }
  
  button:focus {
    outline: 3px solid Highlight;
  }
  
  /* Ensure links are distinguishable */
  a {
    text-decoration: underline;
    color: LinkText;
  }
  
  /* High contrast focus indicators */
  :focus {
    outline: 3px solid Highlight;
    outline-offset: 2px;
  }
}

/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Accessible Notifications

Notifications must be perceivable by all users, including those using screen readers:

// Accessible notification handling
class AccessibleNotifier {
  constructor() {
    this.liveRegion = this.createLiveRegion();
  }
  
  createLiveRegion() {
    const region = document.createElement('div');
    region.setAttribute('role', 'status');
    region.setAttribute('aria-live', 'polite');
    region.setAttribute('aria-atomic', 'true');
    region.className = 'sr-only';
    document.body.appendChild(region);
    return region;
  }
  
  announce(message, priority = 'polite') {
    this.liveRegion.setAttribute('aria-live', priority);
    this.liveRegion.textContent = '';
    
    // Small delay to ensure screen reader picks up change
    setTimeout(() => {
      this.liveRegion.textContent = message;
    }, 100);
  }
  
  async showChromeNotification(title, message, iconUrl) {
    return new Promise((resolve, reject) => {
      chrome.notifications.create(
        {
          type: 'basic',
          iconUrl: iconUrl,
          title: title,
          message: message,
          priority: 1,
          // Accessibility: don't require dismissal
          requireInteraction: false,
        },
        (notificationId) => {
          if (chrome.runtime.lastError) {
            this.announce(`Error: ${chrome.runtime.lastError.message}`, 'assertive');
            reject(chrome.runtime.lastError);
          } else {
            this.announce(`${title}: ${message}`, 'polite');
            resolve(notificationId);
          }
        }
      );
    });
  }
}

// Usage
const notifier = new AccessibleNotifier();

async function handleAction() {
  try {
    await chrome.storage.session.set({ key: 'value' });
    notifier.announce('Settings saved successfully');
  } catch (error) {
    notifier.announce('Failed to save settings. Please try again.', 'assertive');
  }
}

Testing with Chrome Accessibility Tools

Chrome provides built-in accessibility auditing through Lighthouse and the Accessibility Inspector.

Running Accessibility Audits

// Using Chrome's accessibility auditing in extensions
async function runAccessibilityAudit() {
  // Get the active tab
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  
  // Inject a content script to run audits
  const results = await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    function: () => {
      // Check for common accessibility issues
      const issues = [];
      
      // Check for images missing alt text
      const images = document.querySelectorAll('img');
      images.forEach((img, index) => {
        if (!img.hasAttribute('alt') && !img.getAttribute('role')) {
          issues.push({
            severity: 'serious',
            message: `Image at index ${index} missing alt text`,
            element: img.outerHTML.substring(0, 100)
          });
        }
      });
      
      // Check for buttons without accessible names
      const buttons = document.querySelectorAll('button');
      buttons.forEach((btn, index) => {
        if (!btn.getAttribute('aria-label') && 
            !btn.textContent.trim() && 
            !btn.getAttribute('title')) {
          issues.push({
            severity: 'critical',
            message: `Button at index ${index} has no accessible name`,
            element: btn.outerHTML.substring(0, 100)
          });
        }
      });
      
      // Check for form inputs missing labels
      const inputs = document.querySelectorAll('input:not([type="hidden"]):not([aria-hidden])');
      inputs.forEach((input, index) => {
        const hasLabel = input.getAttribute('aria-label') || 
                        input.getAttribute('aria-labelledby') ||
                        document.querySelector(`label[for="${input.id}"]`);
        if (!hasLabel) {
          issues.push({
            severity: 'critical',
            message: `Input at index ${index} missing label`,
            element: input.outerHTML.substring(0, 100)
          });
        }
      });
      
      // Check color contrast (simplified)
      const elements = document.querySelectorAll('p, span, a, h1, h2, h3, h4, h5, h6');
      elements.forEach((el) => {
        const style = window.getComputedStyle(el);
        const bgColor = style.backgroundColor;
        const textColor = style.color;
        
        if (bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
          // Note: This is a simplified check; real contrast checking is more complex
          issues.push({
            severity: 'warning',
            message: 'Element may have contrast issues - verify manually',
            element: el.outerHTML.substring(0, 100)
          });
        }
      });
      
      return {
        url: window.location.href,
        issues: issues,
        timestamp: new Date().toISOString()
      };
    }
  });
  
  return results[0].result;
}

Using the Accessibility Inspector

The Accessibility Inspector in Chrome DevTools provides detailed information about any element’s accessibility properties:

  1. Open Chrome DevTools (F12)
  2. Navigate to the Accessibility tab
  3. Select an element to view its accessibility tree
  4. Check the computed properties, ARIA attributes, and AXObject properties
// Debug script to log accessibility tree
async function logAccessibilityTree(tabId) {
  const results = await chrome.scripting.executeScript({
    target: { tabId },
    function: () => {
      function getAccessibleDescription(el) {
        const aria = el.getAttribute('aria-description');
        const title = el.getAttribute('title');
        const label = el.getAttribute('aria-label');
        const labelledBy = el.getAttribute('aria-labelledby');
        
        return { aria, title, label, labelledBy };
      }
      
      const elements = document.querySelectorAll('button, a, input, select, textarea');
      return Array.from(elements).slice(0, 10).map(el => ({
        tag: el.tagName.toLowerCase(),
        id: el.id || null,
        classes: el.className || null,
        text: el.textContent?.substring(0, 50) || null,
        role: el.getAttribute('role') || null,
        accessibleName: el.name || null,
        description: getAccessibleDescription(el),
        tabIndex: el.getAttribute('tabindex'),
      }));
    }
  });
  
  console.table(results[0].result);
}

WCAG Compliance Checklist for Extension UIs

Use this checklist to ensure your extension meets WCAG 2.1 AA standards:

Perceivable

Operable

Understandable

Robust

Extension-Specific Checks


Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.

No previous article
No next article