Static a11y (auditA11y)
auditA11y checks Domphy element trees (plain objects) for common accessibility violations without a browser or Playwright. It runs in Node, at build time, or in Vitest — anywhere you have access to the element tree before it is rendered.
import { auditA11y } from "@domphy/audit"
const App = {
div: [
{ img: null, src: "logo.png" }, // missing alt → flagged
{ input: null, type: "text" }, // no label → flagged
{ h1: "Dashboard" },
{ h3: "Overview" }, // skipped h2 → flagged
],
}
const result = auditA11y(App)
console.log(result.ok) // false
console.log(result.issues) // A11yIssue[]This is the static pre-render complement to checkContrast / checkLayout (which run on live pages via Playwright).
auditA11y | checkLayout | |
|---|---|---|
| When | Before render (static) | After render (Playwright) |
| Input | Domphy element tree | Live page |
| Playwright needed | No | Yes |
| Checks | alt, label, heading, lang | contrast, overlap, geometry, theme, overlay |
Signature
function auditA11y(element: unknown): A11yResultPass any Domphy element, array of elements, or a full document tree. Returns an A11yResult synchronously.
interface A11yResult {
ok: boolean // false if issues.length > 0
issues: A11yIssue[]
}
interface A11yIssue {
rule: A11yRule // which rule fired
message: string // human-readable description
path: string // dot-path through the element tree
}
type A11yRule =
| "missing-alt"
| "missing-label"
| "heading-hierarchy"
| "missing-lang"Rules
missing-alt — images must have alt
Every { img: ... } must have an alt attribute. Screen readers read the filename aloud when alt is absent, which is never useful.
// Bad — no alt
{ img: null, src: "/hero.jpg" }
// OK — decorative image (alt="" tells screen readers to skip it)
{ img: null, src: "/hero.jpg", alt: "" }
// OK — meaningful image
{ img: null, src: "/avatar.jpg", alt: "Profile photo of Jane Smith" }missing-label — form controls need accessible names
Every <input> (except type="hidden", type="submit", type="reset", type="button", type="image"), <textarea>, and <select> must have an accessible name. Accepted sources (any one is sufficient):
| Source | Example |
|---|---|
Wrapped in <label> | { label: [{ input: null }] } |
<label for="id"> elsewhere in tree | { label: "Name", for: "name-field" } + { input: null, id: "name-field" } |
aria-label attribute | { input: null, "aria-label": "Search" } |
aria-labelledby attribute | { input: null, "aria-labelledby": "heading-id" } |
title attribute (WCAG fallback) | { input: null, title: "Search" } |
placeholder is not an accessible name — screen readers do not expose it reliably and it disappears on focus.
// Bad — no label
{ input: null, type: "text", placeholder: "Search..." }
// OK — aria-label
{ input: null, type: "text", "aria-label": "Search" }
// OK — nested in label
{ label: [
{ span: "Email" },
{ input: null, type: "email" },
]}
// OK — label[for]
[
{ label: "Email", for: "email-field" },
{ input: null, type: "email", id: "email-field" },
]heading-hierarchy — no level skips
Heading levels must increase by at most one step at a time. Screen reader users navigate by headings; a skip (h1 → h3) creates a hole in the outline that implies content is missing.
// Bad — skips h2
[
{ h1: "Dashboard" },
{ h3: "Overview" }, // flagged: missing h2
]
// OK — sequential levels
[
{ h1: "Dashboard" },
{ h2: "Overview" },
{ h3: "Details" },
]
// OK — jumping back up is always valid
[
{ h1: "Dashboard" },
{ h2: "Section A" },
{ h3: "Subsection" },
{ h2: "Section B" }, // fine: h3 → h2 is going up, not down
]missing-lang — <html> needs a lang attribute
Only fires when the element tree includes an { html: ... } element (full-document SSR trees). Screen readers use lang to select the correct speech-synthesis language.
// Bad
{ html: [{ head: null }, { body: [...] }] }
// OK
{ html: [{ head: null }, { body: [...] }], lang: "en" }Usage in Vitest
// src/components/header.test.ts
import { describe, it, expect } from "vitest"
import { auditA11y } from "@domphy/audit"
const Header = {
header: [
{ img: null, src: "/logo.svg", alt: "Acme logo" },
{ nav: [
{ h2: "Navigation" },
{ ul: [
{ li: { a: "Home", href: "/" } },
{ li: { a: "About", href: "/about" } },
]},
]},
],
}
describe("Header a11y", () => {
it("has no a11y violations", () => {
const result = auditA11y(Header)
expect(result.issues).toHaveLength(0)
})
})Combining with @domphy/doctor
auditA11y and @domphy/doctor's diagnose cover different rules — run both for thorough static analysis before rendering:
import { diagnose, format } from "@domphy/doctor"
import { auditA11y } from "@domphy/audit"
function checkElement(element: unknown) {
// Doctor: Domphy-specific rules (inline typography, raw theme values, tone grammar, etc.)
const doctorIssues = diagnose(element)
if (doctorIssues.length > 0) {
console.warn("Doctor issues:\n" + format(doctorIssues))
}
// Audit: WCAG-aligned a11y rules (alt, labels, heading order, lang)
const a11yResult = auditA11y(element)
if (!a11yResult.ok) {
console.warn("A11y issues:")
for (const issue of a11yResult.issues) {
console.warn(` [${issue.rule}] ${issue.path}: ${issue.message}`)
}
}
return doctorIssues.length === 0 && a11yResult.ok
}Limitations
Reactive content is not traversable. Any child tree returned by a
(listener) => ...function is skipped. The element's own props (alt,aria-label, etc.) are still checked — only the reactive children are invisible to the static walker.Dynamic label associations. If a
<label for="...">is built with a reactiveforvalue —{ label: "Name", for: (l) => state.get(l) }— the association cannot be resolved statically. Use a literal string for thefor/idpair.Only four rules. Compared to axe-core's 90+ rules,
auditA11ycovers the highest-impact structural a11y patterns: alt text, accessible labels, heading order, and document language. For contrast checking, usecheckContrast(page)(Playwright-based, checks computed colors at runtime).No ARIA role or attribute validation.
aria-*attribute correctness (valid roles, required owned elements, allowed states) is not currently checked.
Types
import type { A11yIssue, A11yResult, A11yRule } from "@domphy/audit"
type A11yRule =
| "missing-alt"
| "missing-label"
| "heading-hierarchy"
| "missing-lang"
interface A11yIssue {
rule: A11yRule
message: string
path: string // e.g. "root[0]>div>img" or "root>form[2]>input"
}
interface A11yResult {
ok: boolean
issues: A11yIssue[]
}