Skip to main content
Back to Blog
Tutorials
4 min read
December 27, 2024

How to Build an Accessibility Audit Tool in React

Build an automated accessibility audit tool in React that checks for WCAG violations, missing alt text, contrast issues, and ARIA problems.

Ryel Banfield

Founder & Lead Developer

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 &quot;Run audit&quot; 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.

accessibilityaudita11yWCAGReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles