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
altattributes on images - Broken heading hierarchy (h1 → h3, skipping h2)
- Missing form labels
- Empty links
- Missing
langattribute 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"
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);
Running It
node accessibility-checker.js https://dev.to
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.
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;
}
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 }}
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)