Accessibility auditing helps catch issues before users do. Here is how to build an automated checker.
Audit Engine
// lib/a11y-audit.ts
export type Severity = "error" | "warning" | "info";
export interface AuditResult {
id: string;
severity: Severity;
message: string;
element: string;
selector: string;
wcag?: string;
fix?: string;
}
export function runAudit(root: HTMLElement = document.body): AuditResult[] {
const results: AuditResult[] = [];
checkImages(root, results);
checkHeadings(root, results);
checkLinks(root, results);
checkForms(root, results);
checkAria(root, results);
checkContrast(root, results);
checkInteractive(root, results);
return results;
}
function getSelector(el: Element): string {
if (el.id) return `#${el.id}`;
const tag = el.tagName.toLowerCase();
const className = el.className
? `.${el.className.toString().trim().split(/\s+/).join(".")}`
: "";
return `${tag}${className}`;
}
function checkImages(root: HTMLElement, results: AuditResult[]) {
const images = root.querySelectorAll("img");
images.forEach((img) => {
if (!img.alt && !img.getAttribute("role")?.includes("presentation")) {
results.push({
id: "img-alt",
severity: "error",
message: "Image missing alt text",
element: img.outerHTML.slice(0, 120),
selector: getSelector(img),
wcag: "1.1.1",
fix: 'Add an alt attribute describing the image, or role="presentation" for decorative images.',
});
}
if (img.alt && img.alt.length > 125) {
results.push({
id: "img-alt-long",
severity: "warning",
message: "Alt text is very long; consider using a shorter description with aria-describedby for details.",
element: img.outerHTML.slice(0, 120),
selector: getSelector(img),
wcag: "1.1.1",
});
}
});
}
function checkHeadings(root: HTMLElement, results: AuditResult[]) {
const headings = root.querySelectorAll("h1, h2, h3, h4, h5, h6");
let lastLevel = 0;
headings.forEach((h) => {
const level = parseInt(h.tagName[1], 10);
if (level - lastLevel > 1 && lastLevel > 0) {
results.push({
id: "heading-order",
severity: "warning",
message: `Heading level skipped: h${lastLevel} to h${level}`,
element: `<${h.tagName.toLowerCase()}>${h.textContent?.slice(0, 60)}</${h.tagName.toLowerCase()}>`,
selector: getSelector(h),
wcag: "1.3.1",
fix: `Use h${lastLevel + 1} instead, or add intermediate headings.`,
});
}
if (!h.textContent?.trim()) {
results.push({
id: "heading-empty",
severity: "error",
message: "Empty heading element",
element: h.outerHTML.slice(0, 80),
selector: getSelector(h),
wcag: "1.3.1",
fix: "Add text content or remove the empty heading.",
});
}
lastLevel = level;
});
const h1s = root.querySelectorAll("h1");
if (h1s.length === 0) {
results.push({
id: "no-h1",
severity: "warning",
message: "Page has no h1 element",
element: "<body>",
selector: "body",
wcag: "1.3.1",
fix: "Add a single h1 element as the main page heading.",
});
} else if (h1s.length > 1) {
results.push({
id: "multiple-h1",
severity: "warning",
message: `Page has ${h1s.length} h1 elements; prefer one.`,
element: "<h1>...",
selector: "h1",
wcag: "1.3.1",
});
}
}
function checkLinks(root: HTMLElement, results: AuditResult[]) {
const links = root.querySelectorAll("a");
links.forEach((a) => {
const text = a.textContent?.trim() ?? "";
const ariaLabel = a.getAttribute("aria-label");
if (!text && !ariaLabel && !a.querySelector("img[alt]")) {
results.push({
id: "link-name",
severity: "error",
message: "Link has no accessible name",
element: a.outerHTML.slice(0, 120),
selector: getSelector(a),
wcag: "2.4.4",
fix: "Add visible text, an aria-label, or an image with alt text inside the link.",
});
}
if (["click here", "read more", "learn more", "here"].includes(text.toLowerCase())) {
results.push({
id: "link-generic",
severity: "warning",
message: `Generic link text: "${text}"`,
element: a.outerHTML.slice(0, 120),
selector: getSelector(a),
wcag: "2.4.4",
fix: "Use descriptive text that explains where the link goes.",
});
}
});
}
function checkForms(root: HTMLElement, results: AuditResult[]) {
const inputs = root.querySelectorAll("input, select, textarea");
inputs.forEach((input) => {
const type = input.getAttribute("type");
if (type === "hidden" || type === "submit" || type === "button") return;
const id = input.id;
const hasLabel = id && root.querySelector(`label[for="${id}"]`);
const hasAriaLabel = input.getAttribute("aria-label");
const hasAriaLabelledBy = input.getAttribute("aria-labelledby");
if (!hasLabel && !hasAriaLabel && !hasAriaLabelledBy) {
results.push({
id: "input-label",
severity: "error",
message: "Form input missing label",
element: input.outerHTML.slice(0, 120),
selector: getSelector(input),
wcag: "1.3.1",
fix: "Add a <label> element with a matching for attribute, or use aria-label.",
});
}
});
}
function checkAria(root: HTMLElement, results: AuditResult[]) {
const validRoles = [
"alert", "button", "checkbox", "dialog", "heading", "img",
"link", "list", "listitem", "main", "navigation", "region",
"search", "tab", "tablist", "tabpanel", "textbox", "timer",
"banner", "complementary", "contentinfo", "form", "menu",
"menuitem", "presentation", "progressbar", "radio", "status",
"switch", "tooltip", "tree", "treeitem",
];
root.querySelectorAll("[role]").forEach((el) => {
const role = el.getAttribute("role")!;
if (!validRoles.includes(role)) {
results.push({
id: "aria-role-invalid",
severity: "error",
message: `Invalid ARIA role: "${role}"`,
element: el.outerHTML.slice(0, 120),
selector: getSelector(el),
wcag: "4.1.2",
});
}
});
}
function checkContrast(root: HTMLElement, results: AuditResult[]) {
// Simplified check — real contrast checking needs computed styles and color parsing
const textElements = root.querySelectorAll("p, span, a, li, td, th, label, h1, h2, h3, h4, h5, h6");
textElements.forEach((el) => {
const style = window.getComputedStyle(el);
const color = style.color;
const bg = style.backgroundColor;
if (color === bg && color !== "rgba(0, 0, 0, 0)") {
results.push({
id: "contrast-same",
severity: "error",
message: "Text color matches background color",
element: `<${el.tagName.toLowerCase()}>${el.textContent?.slice(0, 40)}</${el.tagName.toLowerCase()}>`,
selector: getSelector(el),
wcag: "1.4.3",
});
}
});
}
function checkInteractive(root: HTMLElement, results: AuditResult[]) {
root.querySelectorAll("[onclick], [onkeydown]").forEach((el) => {
if (!el.getAttribute("role") && !["A", "BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(el.tagName)) {
results.push({
id: "interactive-role",
severity: "warning",
message: "Interactive element missing role attribute",
element: el.outerHTML.slice(0, 120),
selector: getSelector(el),
wcag: "4.1.2",
fix: 'Add role="button" and tabindex="0" for keyboard access.',
});
}
});
}
Audit Panel Component
"use client";
import { useState, useCallback } from "react";
import { runAudit, type AuditResult, type Severity } from "@/lib/a11y-audit";
const severityColors: Record<Severity, string> = {
error: "bg-red-100 text-red-800 border-red-200",
warning: "bg-yellow-100 text-yellow-800 border-yellow-200",
info: "bg-blue-100 text-blue-800 border-blue-200",
};
export function A11yAuditPanel() {
const [results, setResults] = useState<AuditResult[]>([]);
const [filter, setFilter] = useState<Severity | "all">("all");
const handleAudit = useCallback(() => {
const r = runAudit();
setResults(r);
}, []);
const filtered = filter === "all" ? results : results.filter((r) => r.severity === filter);
const counts = {
error: results.filter((r) => r.severity === "error").length,
warning: results.filter((r) => r.severity === "warning").length,
info: results.filter((r) => r.severity === "info").length,
};
return (
<div className="fixed bottom-4 right-4 w-96 max-h-[80vh] bg-background border rounded-xl shadow-xl overflow-hidden z-50">
<div className="p-3 border-b flex items-center justify-between">
<h2 className="font-semibold text-sm">Accessibility Audit</h2>
<button onClick={handleAudit} className="text-xs bg-primary text-primary-foreground px-3 py-1 rounded">
Run audit
</button>
</div>
{results.length > 0 && (
<div className="flex gap-1 p-2 border-b">
<button
onClick={() => setFilter("all")}
className={`text-xs px-2 py-1 rounded ${filter === "all" ? "bg-muted font-medium" : ""}`}
>
All ({results.length})
</button>
<button
onClick={() => setFilter("error")}
className={`text-xs px-2 py-1 rounded ${filter === "error" ? "bg-red-100 font-medium" : ""}`}
>
Errors ({counts.error})
</button>
<button
onClick={() => setFilter("warning")}
className={`text-xs px-2 py-1 rounded ${filter === "warning" ? "bg-yellow-100 font-medium" : ""}`}
>
Warnings ({counts.warning})
</button>
</div>
)}
<div className="overflow-y-auto max-h-[60vh] p-2 space-y-2">
{filtered.map((result, i) => (
<div key={i} className={`border rounded-lg p-3 text-xs ${severityColors[result.severity]}`}>
<div className="flex items-start justify-between gap-2">
<p className="font-medium">{result.message}</p>
{result.wcag && (
<span className="shrink-0 opacity-70">WCAG {result.wcag}</span>
)}
</div>
<code className="block mt-1 text-[10px] opacity-70 truncate">{result.selector}</code>
{result.fix && (
<p className="mt-1 opacity-80">Fix: {result.fix}</p>
)}
</div>
))}
{results.length === 0 && (
<p className="text-center text-sm text-muted-foreground py-8">
Click "Run audit" to check this page.
</p>
)}
</div>
</div>
);
}
Need an Accessibility Audit?
We conduct comprehensive WCAG audits and build accessible interfaces from the ground up. Contact us to improve your site's accessibility.