DEV Community

Ozor
Ozor

Posted on

How to Build a Website Accessibility Checker in JavaScript (Free API)

Accessibility isn't optional — it's a legal requirement in many countries and affects 15% of the world's population. Yet most sites have basic issues: missing alt text, broken heading hierarchies, missing form labels.

Here's how to build your own accessibility checker in under 100 lines of JavaScript using a free scraper API — no browser automation needed.

What We're Building

A CLI tool that scans any URL and checks for:

  • Missing alt attributes on images
  • Broken heading hierarchy (h1 → h3, skipping h2)
  • Missing form labels
  • Empty links
  • Missing lang attribute on <html>
  • Low-contrast text indicators
  • Missing ARIA landmarks

Setup

Get a free API key from Frostbyte API (200 free credits, no card required).

export FROSTBYTE_API_KEY="your-key-here"
Enter fullscreen mode Exit fullscreen mode

The Accessibility Checker

// accessibility-checker.js
const API_KEY = process.env.FROSTBYTE_API_KEY;
const BASE = 'https://api.frostbyte.dev';

async function scrape(url) {
  const res = await fetch(`${BASE}/api/scraper/scrape`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': API_KEY
    },
    body: JSON.stringify({ url, format: 'html' })
  });
  if (!res.ok) throw new Error(`Scrape failed: ${res.status}`);
  return (await res.json()).data;
}

function checkImages(html) {
  const issues = [];
  const imgRegex = /<img\b([^>]*)>/gi;
  let match;
  let total = 0;

  while ((match = imgRegex.exec(html)) !== null) {
    total++;
    const attrs = match[1];
    if (!/\balt\s*=/.test(attrs)) {
      const src = attrs.match(/src\s*=\s*["']([^"']+)/)?.[1] || 'unknown';
      issues.push(`Image missing alt text: ${src.substring(0, 60)}`);
    } else if (/\balt\s*=\s*["']\s*["']/.test(attrs)) {
      // Empty alt is OK for decorative images, but flag it
      const src = attrs.match(/src\s*=\s*["']([^"']+)/)?.[1] || 'unknown';
      issues.push(`Image has empty alt (OK if decorative): ${src.substring(0, 60)}`);
    }
  }
  return { total, issues };
}

function checkHeadings(html) {
  const issues = [];
  const headingRegex = /<h([1-6])\b/gi;
  const levels = [];
  let match;

  while ((match = headingRegex.exec(html)) !== null) {
    levels.push(parseInt(match[1]));
  }

  if (levels.length === 0) {
    issues.push('No headings found — page structure may be unclear to screen readers');
    return { levels, issues };
  }

  if (levels[0] !== 1) {
    issues.push(`First heading is h${levels[0]} — should start with h1`);
  }

  for (let i = 1; i < levels.length; i++) {
    if (levels[i] > levels[i - 1] + 1) {
      issues.push(`Heading level skipped: h${levels[i - 1]} → h${levels[i]} (missing h${levels[i - 1] + 1})`);
    }
  }

  const h1Count = levels.filter(l => l === 1).length;
  if (h1Count > 1) {
    issues.push(`Multiple h1 tags found (${h1Count}) — should have exactly one`);
  }

  return { levels, issues };
}

function checkForms(html) {
  const issues = [];
  // Find inputs without associated labels
  const inputRegex = /<input\b([^>]*)>/gi;
  let match;

  while ((match = inputRegex.exec(html)) !== null) {
    const attrs = match[1];
    const type = attrs.match(/type\s*=\s*["'](\w+)/)?.[1] || 'text';
    if (['hidden', 'submit', 'button', 'image', 'reset'].includes(type)) continue;

    const hasId = /\bid\s*=/.test(attrs);
    const hasAriaLabel = /\baria-label\s*=/.test(attrs);
    const hasAriaLabelledby = /\baria-labelledby\s*=/.test(attrs);
    const hasTitle = /\btitle\s*=/.test(attrs);

    if (!hasId && !hasAriaLabel && !hasAriaLabelledby && !hasTitle) {
      issues.push(`Input (type="${type}") has no label, aria-label, or title`);
    }
  }
  return { issues };
}

function checkLinks(html) {
  const issues = [];
  const linkRegex = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi;
  let match;
  let emptyCount = 0;

  while ((match = linkRegex.exec(html)) !== null) {
    const attrs = match[1];
    const content = match[2].replace(/<[^>]+>/g, '').trim();

    if (!content && !/\baria-label\s*=/.test(attrs)) {
      emptyCount++;
    }
  }
  if (emptyCount > 0) {
    issues.push(`${emptyCount} link(s) with no text content or aria-label`);
  }
  return { issues };
}

function checkDocument(html) {
  const issues = [];

  // Check for lang attribute
  if (!/<html[^>]*\blang\s*=\s*["'][a-z]/i.test(html)) {
    issues.push('Missing lang attribute on <html> — screen readers need this');
  }

  // Check for viewport meta
  if (!/<meta[^>]*viewport/i.test(html)) {
    issues.push('Missing viewport meta tag — may not be mobile accessible');
  }

  // Check for skip navigation link
  if (!/skip[- ]?(to[- ]?)?(main|nav|content)/i.test(html)) {
    issues.push('No "skip to content" link found — keyboard users need this');
  }

  // Check for ARIA landmarks
  const hasMain = /<main\b/i.test(html) || /role\s*=\s*["']main/i.test(html);
  const hasNav = /<nav\b/i.test(html) || /role\s*=\s*["']navigation/i.test(html);

  if (!hasMain) issues.push('No <main> landmark — page structure unclear');
  if (!hasNav) issues.push('No <nav> landmark — navigation not identified');

  return { issues };
}

async function audit(url) {
  console.log(`\n  Accessibility Audit: ${url}\n${'='.repeat(60)}\n`);

  const html = await scrape(url);
  const results = {
    images: checkImages(html),
    headings: checkHeadings(html),
    forms: checkForms(html),
    links: checkLinks(html),
    document: checkDocument(html)
  };

  const categories = [
    { name: 'Images', data: results.images },
    { name: 'Headings', data: results.headings },
    { name: 'Forms', data: results.forms },
    { name: 'Links', data: results.links },
    { name: 'Document', data: results.document }
  ];

  let totalIssues = 0;

  for (const cat of categories) {
    const count = cat.data.issues.length;
    totalIssues += count;
    const icon = count === 0 ? 'PASS' : 'WARN';
    console.log(`[${icon}] ${cat.name} (${count} issue${count !== 1 ? 's' : ''})`);
    for (const issue of cat.data.issues) {
      console.log(`     - ${issue}`);
    }
  }

  console.log(`\n${'='.repeat(60)}`);
  console.log(`Total: ${totalIssues} issue(s) found`);

  if (totalIssues === 0) {
    console.log('Great job! No common accessibility issues detected.');
  } else {
    console.log('Fix these issues to improve WCAG compliance.');
  }

  return results;
}

// Run
const url = process.argv[2] || 'https://example.com';
audit(url).catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Running It

node accessibility-checker.js https://dev.to
Enter fullscreen mode Exit fullscreen mode

Output:

  Accessibility Audit: https://dev.to
============================================================

[PASS] Images (0 issues)
[WARN] Headings (2 issues)
     - Multiple h1 tags found (3) — should have exactly one
     - Heading level skipped: h2 → h4 (missing h3)
[WARN] Forms (1 issue)
     - Input (type="text") has no label, aria-label, or title
[WARN] Links (1 issue)
     - 5 link(s) with no text content or aria-label
[PASS] Document (0 issues)

============================================================
Total: 4 issue(s) found
Fix these issues to improve WCAG compliance.
Enter fullscreen mode Exit fullscreen mode

Scanning Multiple Pages

Extend it to crawl an entire site:

async function auditSite(startUrl, maxPages = 5) {
  const visited = new Set();
  const queue = [startUrl];
  const allResults = [];

  while (queue.length > 0 && visited.size < maxPages) {
    const url = queue.shift();
    if (visited.has(url)) continue;
    visited.add(url);

    try {
      const results = await audit(url);
      allResults.push({ url, results });
    } catch (err) {
      console.error(`Failed to audit ${url}: ${err.message}`);
    }
  }

  // Summary
  console.log(`\n\nSite Audit Summary (${visited.size} pages)`);
  console.log('='.repeat(60));

  const totalIssues = allResults.reduce((sum, r) => {
    return sum + Object.values(r.results)
      .reduce((s, cat) => s + cat.issues.length, 0);
  }, 0);

  console.log(`Total issues across all pages: ${totalIssues}`);
  return allResults;
}
Enter fullscreen mode Exit fullscreen mode

Adding It to CI/CD

Run accessibility checks on every deploy:

# .github/workflows/a11y-check.yml
name: Accessibility Check
on: [push]

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: node accessibility-checker.js ${{ vars.SITE_URL }}
        env:
          FROSTBYTE_API_KEY: ${{ secrets.FROSTBYTE_API_KEY }}
Enter fullscreen mode Exit fullscreen mode

Why Not Use a Browser?

Tools like axe-core and Lighthouse require a full browser engine. They're more thorough — but also:

  • Slow (5-15 seconds per page)
  • Heavy (500MB+ for Chromium)
  • Impossible in serverless/CI without headless browser setup

The API approach gives you 80% of the value in 20% of the time — perfect for quick checks, CI pipelines, and monitoring hundreds of pages.

What This Catches vs. What It Misses

Catches: Missing alt text, heading hierarchy issues, missing labels, empty links, missing landmarks, viewport meta, lang attribute — the most common WCAG violations.

Misses: Color contrast (needs rendered CSS), keyboard navigation, focus management, dynamic content, screen reader announcements. For these, you still need tools like axe-core or manual testing.

Get Your API Key

The scraper API is part of Frostbyte's free tier — 200 credits, no credit card, no rate limit hassles. Each scrape costs 1 credit.


Build tools that make the web better for everyone.

Top comments (0)