Build an Accessibility Checker Chrome Extension: WCAG Compliance Scanner

31 min read

Build an Accessibility Checker Chrome Extension: WCAG Compliance Scanner

Build an Accessibility Checker Chrome Extension: WCAG Compliance Scanner

Web accessibility is not just a legal requirement—it is a moral imperative that ensures digital experiences are inclusive for everyone, regardless of ability. With over 1.3 billion people living with some form of disability worldwide, creating accessible websites has become a critical responsibility for web developers and businesses alike. The Web Content Accessibility Guidelines (WCAG) provide a comprehensive framework for building inclusive digital experiences, but manually auditing websites for accessibility compliance is time-consuming and prone to human error.

This is where a chrome extension accessibility checker becomes invaluable. In this comprehensive guide, we will walk you through building a fully functional WCAG compliance scanner as a Chrome extension. By the end of this tutorial, you will have a working a11y scanner extension that can detect common accessibility issues, provide actionable remediation suggestions, and help developers ensure their websites meet WCAG 2.1 AA standards.


Why Build an Accessibility Checker Chrome Extension?

Before diving into the code, let us explore why creating a dedicated accessibility auditing tool as a Chrome extension makes perfect sense in today’s web development landscape.

The Accessibility Gap

Despite increased awareness about web accessibility, the majority of websites still fail to meet basic WCAG standards. A recent study found that over 98% of the top one million websites have at least one accessibility issue. This widespread problem exists because:

  1. Lack of Awareness: Many developers simply do not know what accessibility issues look like
  2. Time Constraints: Manual accessibility auditing takes hours of specialized work
  3. Limited Tools: Professional accessibility tools can be expensive or complex
  4. Dynamic Content: Modern web apps change constantly, requiring ongoing monitoring

A chrome extension accessibility checker solves these problems by placing powerful scanning capabilities directly in the browser where developers already work. It provides instant feedback, integrates seamlessly into development workflows, and makes accessibility checking as easy as running a linter or automated test.

The Power of Browser-Based Scanning

Chrome extensions have unique advantages for accessibility auditing. They can:

  • Access the DOM directly: Unlike external scanners, our extension can inspect the fully rendered page including dynamic content
  • Analyze computed styles: We can check contrast ratios, font sizes, and other CSS-dependent properties
  • Intercept user interactions: Test keyboard navigation, focus management, and screen reader behavior
  • Run in context: Analyze pages as users actually experience them

These capabilities make a chrome extension a perfect platform for building an effective WCAG checker chrome extension.


Project Architecture

Our accessibility checker extension will consist of several key components working together to provide comprehensive scanning capabilities.

Extension Components Overview

The extension architecture includes:

  1. Manifest V3 Configuration: Defines permissions, content scripts, and extension behavior
  2. Content Script (scanner.js): Performs DOM analysis and accessibility audits
  3. Popup Interface: Displays scan results in a user-friendly format
  4. Background Service Worker: Handles communication and long-running tasks
  5. WCAG Rules Engine: Contains the logic for checking various accessibility criteria

This modular design allows us to extend the scanner with new rules easily while maintaining clean separation of concerns.


Setting Up the Manifest

Every Chrome extension starts with the manifest file. For our WCAG checker chrome extension, we need to declare the appropriate permissions and define our content scripts.

{
  "manifest_version": 3,
  "name": "A11y Scanner Pro - WCAG Compliance Checker",
  "version": "1.0.0",
  "description": "Automated WCAG 2.1 AA accessibility checker and compliance scanner",
  "permissions": [
    "activeTab",
    "storage",
    "scripting"
  ],
  "host_permissions": [
    "<all_urls>"
  ],
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["scanner.js"],
      "run_at": "document_idle"
    }
  ],
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

Key manifest configuration decisions:

  • host_permissions: We request access to all URLs so the extension can scan any website
  • content_scripts: The scanner runs automatically on every page load
  • scripting permission: Allows us to inject scripts for deep analysis
  • Service Worker: Enables background processing for complex scans

Building the WCAG Scanner Engine

The heart of our a11y scanner extension is the scanning engine that analyzes web pages against WCAG criteria. Let us build a comprehensive scanner that checks for common accessibility violations.

Core Scanner Implementation

Create a file named scanner.js that will contain all our scanning logic:

// scanner.js - WCAG Compliance Scanner Engine

class WCAGScanner {
  constructor() {
    this.issues = [];
    this.wcagCriteria = this.loadWcagCriteria();
  }

  loadWcagCriteria() {
    return {
      // Perceivable
      '1.1.1': { name: 'Non-text Content', level: 'A', category: 'perceivable' },
      '1.3.1': { name: 'Info and Relationships', level: 'A', category: 'perceivable' },
      '1.4.1': { name: 'Use of Color', level: 'A', category: 'perceivable' },
      '1.4.3': { name: 'Contrast (Minimum)', level: 'AA', category: 'perceivable' },
      '1.4.4': { name: 'Resize Text', level: 'AA', category: 'perceivable' },
      
      // Operable
      '2.1.1': { name: 'Keyboard', level: 'A', category: 'operable' },
      '2.4.1': { name: 'Bypass Blocks', level: 'A', category: 'operable' },
      '2.4.2': { name: 'Page Titled', level: 'A', category: 'operable' },
      '2.4.3': { name: 'Focus Order', level: 'A', category: 'operable' },
      '2.4.4': { name: 'Link Purpose (In Context)', level: 'A', category: 'operable' },
      
      // Understandable
      '3.1.1': { name: 'Language of Page', level: 'A', category: 'understandable' },
      '3.3.1': { name: 'Error Identification', level: 'A', category: 'understandable' },
      
      // Robust
      '4.1.1': { name: 'Parsing', level: 'A', category: 'robust' },
      '4.1.2': { name: 'Name, Role, Value', level: 'A', category: 'robust' }
    };
  }

  async runFullScan() {
    this.issues = [];
    
    // Run all scanners in sequence
    await this.checkImages();
    await this.checkHeadings();
    await this.checkLinks();
    await this.checkContrast();
    await this.checkKeyboardAccessibility();
    await this.checkAriaAttributes();
    await this.checkFormLabels();
    await this.checkLanguageAttributes();
    await this.checkFocusManagement();
    
    return this.issues;
  }

This foundational code sets up our scanner class with a structured approach to WCAG compliance checking. Each method will analyze specific aspects of the page for accessibility issues.

Image Accessibility Checker

Images are one of the most common sources of accessibility issues. Our scanner checks for missing alt text and improper alt text usage:

async checkImages() {
  const images = document.querySelectorAll('img');
  
  images.forEach((img, index) => {
    // Check for missing alt attribute
    if (!img.hasAttribute('alt')) {
      // Check if image is decorative (empty alt is acceptable)
      const role = img.getAttribute('role');
      if (role !== 'presentation' && role !== 'none') {
        this.issues.push({
          wcag: '1.1.1',
          criteria: this.wcagCriteria['1.1.1'],
          element: this.sanitizeElement(img),
          message: 'Image is missing alt attribute',
          severity: 'critical',
          selector: this.getSelector(img)
        });
      }
    } else {
      const altText = img.getAttribute('alt').trim();
      
      // Check for generic alt text
      if (['image', 'picture', 'photo', 'graphic'].includes(altText.toLowerCase())) {
        this.issues.push({
          wcag: '1.1.1',
          criteria: this.wcagCriteria['1.1.1'],
          element: this.sanitizeElement(img),
          message: 'Alt text is too generic. Describe the image content.',
          severity: 'major',
          selector: this.getSelector(img)
        });
      }
    }
  });
}

Color Contrast Analyzer

WCAG requires sufficient color contrast for text readability. Our contrast checker calculates the contrast ratio between text and background colors:

async checkContrast() {
  const textElements = document.querySelectorAll(
    'p, h1, h2, h3, h4, h5, h6, span, a, li, td, th, label, button, input'
  );
  
  textElements.forEach(element => {
    const styles = window.getComputedStyle(element);
    const textColor = this.parseColor(styles.color);
    const bgColor = this.parseColor(styles.backgroundColor);
    
    if (!textColor || !bgColor) return;
    
    const contrastRatio = this.calculateContrastRatio(textColor, bgColor);
    const fontSize = parseFloat(styles.fontSize);
    const fontWeight = parseFloat(styles.fontWeight) || 400;
    
    // Determine required contrast ratio based on text size
    const isLargeText = fontSize >= 18.66 || (fontSize >= 14 && fontWeight >= 700);
    const requiredRatio = isLargeText ? 3 : 4.5;
    
    if (contrastRatio < requiredRatio) {
      this.issues.push({
        wcag: '1.4.3',
        criteria: this.wcagCriteria['1.4.3'],
        element: this.sanitizeElement(element),
        message: `Contrast ratio is ${contrastRatio.toFixed(2)}:1 (required: ${requiredRatio}:1 for ${isLargeText ? 'large' : 'normal'} text)`,
        severity: contrastRatio < 3 ? 'critical' : 'major',
        selector: this.getSelector(element),
        details: { contrastRatio, requiredRatio, isLargeText }
      });
    }
  });
}

parseColor(colorString) {
  if (!colorString || colorString === 'transparent' || colorString === 'rgba(0, 0, 0, 0)') {
    return null;
  }
  
  // Handle rgb/rgba format
  const rgbMatch = colorString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
  if (rgbMatch) {
    return {
      r: parseInt(rgbMatch[1]),
      g: parseInt(rgbMatch[2]),
      b: parseInt(rgbMatch[3])
    };
  }
  
  // Handle hex format
  if (colorString.startsWith('#')) {
    const hex = colorString.slice(1);
    return {
      r: parseInt(hex.substr(0, 2), 16),
      g: parseInt(hex.substr(2, 2), 16),
      b: parseInt(hex.substr(4, 2), 16)
    };
  }
  
  return null;
}

calculateContrastRatio(color1, color2) {
  const lum1 = this.getLuminance(color1);
  const lum2 = this.getLuminance(color2);
  const brightest = Math.max(lum1, lum2);
  const darkest = Math.min(lum1, lum2);
  return (brightest + 0.05) / (darkest + 0.05);
}

getLuminance(color) {
  const [r, g, b] = [color.r, color.g, color.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;
}

Keyboard Accessibility Checker

Keyboard accessibility is fundamental for users who cannot use a mouse. Our scanner verifies that all interactive elements are keyboard-accessible:

async checkKeyboardAccessibility() {
  const interactiveElements = document.querySelectorAll(
    'a, button, input, select, textarea, [contenteditable], [role="button"], [role="link"], [role="menuitem"]'
  );
  
  interactiveElements.forEach(element => {
    const tabIndex = element.getAttribute('tabindex');
    const isFocusable = tabIndex !== '-1' || (
      element.tagName !== 'INPUT' || 
      element.tagName !== 'TEXTAREA' || 
      !element.disabled
    );
    
    // Check for positive tabindex (can cause focus order issues)
    if (tabIndex && parseInt(tabIndex) > 0) {
      this.issues.push({
        wcag: '2.4.3',
        criteria: this.wcagCriteria['2.4.3'],
        element: this.sanitizeElement(element),
        message: 'Positive tabindex can disrupt natural focus order. Use 0 or -1 only.',
        severity: 'major',
        selector: this.getSelector(element)
      });
    }
    
    // Check for missing keyboard handlers on custom interactive elements
    const role = element.getAttribute('role');
    if (role && ['button', 'link', 'menuitem', 'checkbox', 'radio', 'slider'].includes(role)) {
      const hasClickHandler = element.onclick || element.getAttribute('onclick');
      const hasKeyHandler = element.onkeydown || element.onkeyup || element.onkeypress;
      
      if (!hasClickHandler && !hasKeyHandler) {
        this.issues.push({
          wcag: '2.1.1',
          criteria: this.wcagCriteria['2.1.1'],
          element: this.sanitizeElement(element),
          message: 'Interactive element has role but may not be keyboard accessible',
          severity: 'critical',
          selector: this.getSelector(element)
        });
      }
    }
  });
}

ARIA Validation Scanner

Proper ARIA (Accessible Rich Internet Applications) usage is critical for screen reader users. Our scanner validates ARIA attributes:

async checkAriaAttributes() {
  const elementsWithAria = document.querySelectorAll('[role], [aria-*]');
  
  elementsWithAria.forEach(element => {
    const role = element.getAttribute('role');
    const validRoles = this.getValidRoles();
    
    // Check for invalid roles
    if (role && !validRoles.includes(role)) {
      this.issues.push({
        wcag: '4.1.2',
        criteria: this.wcagCriteria['4.1.2'],
        element: this.sanitizeElement(element),
        message: `Invalid ARIA role: "${role}"`,
        severity: 'critical',
        selector: this.getSelector(element)
      });
    }
    
    // Check for required associated elements
    if (role === 'combobox') {
      if (!element.getAttribute('aria-expanded') || !element.getAttribute('aria-controls')) {
        this.issues.push({
          wcag: '4.1.2',
          criteria: this.wcagCriteria['4.1.2'],
          element: this.sanitizeElement(element),
          message: 'Combobox must have aria-expanded and aria-controls attributes',
          severity: 'critical',
          selector: this.getSelector(element)
        });
      }
    }
    
    // Check for label associations
    const hasLabel = element.getAttribute('aria-label') || 
                     element.getAttribute('aria-labelledby') ||
                     document.querySelector(`label[for="${element.id}"]`);
    
    if ((role && role !== 'presentation' && role !== 'none') && 
        !['img', 'input', 'select', 'textarea'].includes(element.tagName.toLowerCase()) &&
        !hasLabel) {
      // Check if parent label exists
      const parentLabel = element.closest('label');
      if (!parentLabel) {
        this.issues.push({
          wcag: '1.3.1',
          criteria: this.wcagCriteria['1.3.1'],
          element: this.sanitizeElement(element),
          message: 'Interactive element should have accessible name via aria-label, aria-labelledby, or associated label',
          severity: 'major',
          selector: this.getSelector(element)
        });
      }
    }
  });
}

getValidRoles() {
  return [
    'alert', 'alertdialog', 'application', 'article', 'banner', 'button', 
    'cell', 'checkbox', 'columnheader', 'combobox', 'complementary', 
    'contentinfo', 'definition', 'dialog', 'directory', 'document', 
    'feed', 'figure', 'form', 'grid', 'gridcell', 'group', 'heading', 
    'img', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 
    'marquee', 'math', 'menu', 'menubar', 'menuitem', 'menuitemcheckbox', 
    'menuitemradio', 'navigation', 'none', 'note', 'option', 'presentation', 
    'progressbar', 'radio', 'radiogroup', 'region', 'row', 'rowgroup', 
    'rowheader', 'scrollbar', 'search', 'searchbox', 'separator', 'slider', 
    'spinbutton', 'status', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 
    'term', 'textbox', 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem'
  ];
}

The popup provides the user interface for viewing scan results. Create popup.html and popup.js:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>A11y Scanner Pro</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { width: 400px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
    .header { background: #2b6cb0; color: white; padding: 16px; text-align: center; }
    .header h1 { font-size: 18px; margin-bottom: 4px; }
    .header p { font-size: 12px; opacity: 0.9; }
    .controls { padding: 16px; border-bottom: 1px solid #e2e8f0; }
    .btn { width: 100%; padding: 12px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600; }
    .btn-primary { background: #2b6cb0; color: white; }
    .btn-primary:hover { background: #2c5282; }
    .btn-primary:disabled { background: #a0aec0; cursor: not-allowed; }
    .summary { display: flex; padding: 16px; gap: 12px; border-bottom: 1px solid #e2e8f0; }
    .stat { flex: 1; text-align: center; }
    .stat-value { font-size: 24px; font-weight: 700; }
    .stat-label { font-size: 11px; color: #718096; text-transform: uppercase; }
    .critical .stat-value { color: #e53e3e; }
    .major .stat-value { color: #dd6b20; }
    .minor .stat-value { color: #d69e2e; }
    .results { max-height: 400px; overflow-y: auto; }
    .issue { padding: 12px 16px; border-bottom: 1px solid #e2e8f0; }
    .issue-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; }
    .issue-wcag { font-size: 11px; font-weight: 600; color: #2b6cb0; background: #ebf8ff; padding: 2px 6px; border-radius: 4px; }
    .issue-severity { font-size: 10px; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; }
    .severity-critical { background: #fed7d7; color: #c53030; }
    .severity-major { background: #feebc8; color: #c05621; }
    .severity-minor { background: #fefcbf; color: #975a16; }
    .issue-message { font-size: 13px; color: #2d3748; margin-bottom: 8px; }
    .issue-element { font-size: 11px; color: #718096; font-family: monospace; background: #f7fafc; padding: 4px 8px; border-radius: 4px; }
    .empty { padding: 40px; text-align: center; color: #718096; }
  </style>
</head>
<body>
  <div class="header">
    <h1>A11y Scanner Pro</h1>
    <p>WCAG 2.1 AA Compliance Checker</p>
  </div>
  
  <div class="controls">
    <button id="scanBtn" class="btn btn-primary">Scan This Page</button>
  </div>
  
  <div id="summary" class="summary" style="display: none;">
    <div class="stat critical">
      <div id="criticalCount" class="stat-value">0</div>
      <div class="stat-label">Critical</div>
    </div>
    <div class="stat major">
      <div id="majorCount" class="stat-value">0</div>
      <div class="stat-label">Major</div>
    </div>
    <div class="stat minor">
      <div id="minorCount" class="stat-value">0</div>
      <div class="stat-label">Minor</div>
    </div>
  </div>
  
  <div id="results" class="results"></div>
  
  <script src="popup.js"></script>
</body>
</html>

The popup script handles communication with the content scanner and displays results:

// popup.js

document.addEventListener('DOMContentLoaded', () => {
  const scanBtn = document.getElementById('scanBtn');
  const summary = document.getElementById('summary');
  const resultsContainer = document.getElementById('results');
  
  scanBtn.addEventListener('click', runScan);
  
  async function runScan() {
    scanBtn.disabled = true;
    scanBtn.textContent = 'Scanning...';
    
    try {
      // Get the active tab
      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
      
      // Execute the scanner
      const results = await chrome.tabs.sendMessage(tab.id, { action: 'runScan' });
      
      displayResults(results);
    } catch (error) {
      console.error('Scan error:', error);
      resultsContainer.innerHTML = `
        <div class="empty">
          <p>Unable to scan this page.</p>
          <p style="font-size: 12px; margin-top: 8px;">Try refreshing the page and scanning again.</p>
        </div>
      `;
    } finally {
      scanBtn.disabled = false;
      scanBtn.textContent = 'Scan This Page';
    }
  }
  
  function displayResults(issues) {
    if (!issues || issues.length === 0) {
      summary.style.display = 'none';
      resultsContainer.innerHTML = `
        <div class="empty">
          <p style="font-size: 24px; margin-bottom: 8px;">✅</p>
          <p>No accessibility issues found!</p>
        </div>
      `;
      return;
    }
    
    // Count by severity
    const counts = { critical: 0, major: 0, minor: 0 };
    issues.forEach(issue => counts[issue.severity]++);
    
    document.getElementById('criticalCount').textContent = counts.critical;
    document.getElementById('majorCount').textContent = counts.major;
    document.getElementById('minorCount').textContent = counts.minor;
    summary.style.display = 'flex';
    
    // Render issues
    resultsContainer.innerHTML = issues.map(issue => `
      <div class="issue">
        <div class="issue-header">
          <span class="issue-wcag">WCAG ${issue.wcag}</span>
          <span class="issue-severity severity-${issue.severity}">${issue.severity}</span>
        </div>
        <p class="issue-message">${issue.message}</p>
        <code class="issue-element">${issue.selector}</code>
      </div>
    `).join('');
  }
});

// Listen for messages from content script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === 'scanComplete') {
    displayResults(message.issues);
  }
});

Connecting Components with Background Script

The background script serves as the bridge between the content scanner and the popup:

// background.js

// Handle messages between popup and content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === 'runScan') {
    // Forward to the active tab's content script
    chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
      if (tabs[0]) {
        chrome.tabs.sendMessage(tabs[0].id, { action: 'performScan' }, (response) => {
          sendResponse(response);
        });
      }
    });
    return true; // Keep channel open for async response
  }
});

// Initialize scanner when page loads
chrome.runtime.onInstalled.addListener(() => {
  console.log('A11y Scanner Pro installed');
});

Testing Your Extension

Now that we have built all the components, let us test our accessibility checker extension:

  1. Load the extension in Chrome:
    • Navigate to chrome://extensions/
    • Enable “Developer mode” (toggle in top right)
    • Click “Load unpacked”
    • Select your extension’s folder
  2. Test on a sample page:
    • Visit any website (try Wikipedia or a complex web app)
    • Click the extension icon in the toolbar
    • Click “Scan This Page”
    • Review the accessibility issues found
  3. Verify WCAG compliance:
    • Test with known inaccessible sites to ensure detection
    • Verify that contrast calculations are accurate
    • Check that WCAG criteria references are correct

Extending the Scanner

The architecture we have built makes it easy to add new scanning capabilities. Here are some ideas for extending your a11y scanner extension:

Additional Checks to Implement

  1. Heading Structure: Ensure proper heading hierarchy (h1 → h2 → h3)
  2. Form Validation: Check for proper error messages and validation
  3. Motion and Animation: Verify respects for prefers-reduced-motion
  4. Focus Visibility: Ensure visible focus indicators on all interactive elements
  5. Touch Targets: Verify minimum 44x44 pixel touch target sizes
  6. Language Changes: Check for proper lang attributes on multilingual content

Advanced Features

Consider adding these advanced capabilities:

  • Screenshot Capture: Document issues with visual evidence
  • Export Reports: Generate PDF or CSV accessibility reports
  • Integration with CI/CD: Auto-scan during development
  • Historical Tracking: Track accessibility scores over time
  • Fix Suggestions: Provide code snippets to fix each issue

Conclusion

Building an accessibility checker Chrome extension is a rewarding project that addresses a real need in the web development community. Our WCAG compliance scanner demonstrates the core principles of automated accessibility testing while providing a solid foundation for expansion.

Key takeaways from this guide:

  1. Chrome extensions are ideal for accessibility testing because they can access the fully rendered DOM and analyze pages as users experience them
  2. A comprehensive accessibility checker must address multiple WCAG criteria including images, contrast, keyboard navigation, ARIA, and forms
  3. Modular architecture allows easy extension with new scanning rules
  4. User-friendly presentation of results is critical for developer adoption

By completing this guide, you have built a functional a11y scanner extension that can help developers create more accessible websites. Remember that automated tools catch only about 30-40% of accessibility issues—manual testing with actual users of assistive technologies remains essential for comprehensive accessibility assurance.

Start using your accessibility checker on your projects today, and continue learning about WCAG to build even more sophisticated scanning capabilities. The web needs more developers who care about accessibility, and your chrome extension accessibility checker is a step in the right direction.


Additional Resources


This guide is part of the Chrome Extension Guide series. For more tutorials on building powerful Chrome extensions, explore our other comprehensive guides on tab management, memory optimization, and developer productivity tools.

No previous article
No next article