Generate an accessibility statement from audit results you actually ran.
Most accessibility statement generators are a form. You pick "WCAG 2.1 AA" from a dropdown, type your company name, and it hands you a page asserting conformance nobody measured. That page is a legal claim. This tool will not write one.
Instead it takes the output of a real audit and renders a statement plus a per-criterion conformance table, where every status traces back to something that was tested.
That is this tool's actual output, rendered from AccessSpark's own published
/api/accessibility/status. No mockup and no hand-edited numbers.
These are in code, not documentation, because the entire point is that you cannot talk the tool into overclaiming:
- Untested means untested. Any criterion without a measurable status is reported as "Not Evaluated", never assumed to pass.
- AAA is never claimed as conforming. If your input marks a Level AAA criterion as "Supports", it is downgraded to "Not Evaluated" with a note. An automated pass is not grounds for an AAA claim.
- No measurements, no statement. Given input with no criteria behind it, or input where every criterion came back unmeasured, the tool exits non-zero rather than rendering a confident-looking page with nothing behind it.
- A reported failure is never quietly dropped. Status matching ignores case and spacing, so an auditor that writes "Does not support" is not silently reinterpreted as "Not Evaluated".
- The output states its own limits. The rendered page says that automated testing cannot verify every success criterion, and describes the scope that was measured.
Zero dependencies. Node 18 or newer.
node render.mjs status.json > accessibility.html
cat status.json | node render.mjs > accessibility.html
node render.mjs axe.json --from-axe --org "Acme Inc" > accessibility.html
node render.mjs status.json --fragment > statement.html
node render.mjs status.json --css brand.css > accessibility.html| Flag | Effect |
|---|---|
--from-axe |
Treat the input as raw axe-core results instead of the criteria contract. |
--fragment |
Emit the statement markup only, with no document shell and no styles. |
--css <file> |
Append a stylesheet to the standalone output, after the defaults. |
--contact <email or url> |
Adds a "report a barrier" link. A bare address becomes a mailto:. |
--org, --url, --standard |
Override the matching field in the JSON. |
If you run axe anywhere (directly, or through Lighthouse, Pa11y, playwright-axe
or cypress-axe, which all wrap it), pass the results with --from-axe. It
accepts a single axe result object or an array of them, so several pages can be
merged into one statement.
axe tags each rule with the criteria it maps to, and the adapter reads those tags:
| Where the rule appeared | Status it produces |
|---|---|
violations |
Does Not Support |
incomplete |
Not Evaluated, noted as needing manual review |
passes |
Supports |
inapplicable |
Not Evaluated (see below) |
| No axe rule covers the criterion | Not Evaluated |
Two decisions worth knowing about:
inapplicable is ignored. It means the page contained no elements the rule
applies to. A page with no video does not thereby support the captions criteria,
so treating "inapplicable" as a pass would invent conformance out of absence.
Nothing here ever produces "Partially Supports". That status carries a judgment about severity and scope that automated output does not support, and inventing it is exactly the overclaim this tool exists to prevent.
The adapter lists all 55 Level A and AA success criteria in WCAG 2.2, including the ones no axe rule covers. Expect a lot of "Not Evaluated": axe is good at what it checks and it does not check most of the standard. Seeing that in your own statement is the point.
example-axe.json is a real axe-core 4.13 run, trimmed to the id and tags
fields the adapter reads. Try it:
node render.mjs example-axe.json --from-axe --org "Example Inc"It produces 7 Supports and 48 Not Evaluated out of 55. That ratio is the honest picture of what a passing automated run actually establishes, and the test suite asserts it stays lopsided. If a change ever made this mostly green, the mapping would be overclaiming.
Any tool that can emit the shape below. The contract matches the public
/api/accessibility/status endpoint that AccessSpark
serves for its own site, so if you use AccessSpark you can point this straight at
it.
| Field | Type | Notes |
|---|---|---|
criteriaDetail[] |
array | Preferred. One entry per success criterion. |
sampleCriteria[] |
array | Fallback if criteriaDetail is absent. |
organization |
string | Overridable with --org. |
url |
string | Overridable with --url. |
standard |
string | Defaults to WCAG 2.2 AA. Overridable with --standard. |
lastVerified |
ISO date string | Rendered as the assessment date. |
pagesScanned |
number | Rendered as the scope. |
Each criterion entry:
| Field | Type | Notes |
|---|---|---|
id |
string | e.g. 1.4.3. Entries without an id are dropped. |
level |
string | A, AA, or AAA. Filled in from WCAG 2.2 if you omit it. |
status |
string | Supports, Partially Supports, Does Not Support. Case-insensitive. Anything else becomes Not Evaluated. |
name |
string | Optional. Filled in from WCAG 2.2 if you omit it. |
Try it with the included sample, which deliberately contains an AAA criterion
marked Supports and a criterion with an empty status, so you can see both rules
fire:
node render.mjs example-status.json > example.htmlThe default output is a self-contained page with a small neutral style block. You have two ways to make it yours.
Fragment mode emits the markup and nothing else: no doctype, no <head>, no
<style>, no skip link. Drop it into a page, template or component that already
has its own shell and styling.
node render.mjs status.json --fragment > _statement.htmlThe markup uses stable, prefixed class names so it will not collide with yours:
| Class | On |
|---|---|
as-statement |
The outer wrapper |
as-eyebrow, as-lede |
The kicker above the h1, and the intro paragraph |
as-statusbar, as-dot |
The date and scope strip, and its status dot |
as-section, as-num |
Each numbered section, and its ordinal badge |
as-card, as-card--accent, as-label |
Card containers and a card's inline label |
as-stats, as-stat, as-stat__sub |
The summary cards, with --pass, --warn, --fail modifiers |
as-table, as-note |
The criterion table and a per-row note |
as-status--supports, --partial, --fails |
The status word inside a row |
as-scope |
The scope line under the summary |
Everything else is plain semantic HTML: one h1, an h2 per section, a dl for
the summary counts, and a table with a caption and th scope on both axes.
The ordinal badges are aria-hidden, so a screen reader hears the heading text
without the numbering. The fragment starts at h1, so if you are embedding it
under an existing h1, demote the headings in your template.
CSS override keeps the standalone page and appends your stylesheet after the defaults, so redefining the variables is enough to reskin it:
:root {
--as-fg: #1e293b; /* headings, table text, stat labels */
--as-fg-muted: #475569; /* body copy, notes, sub-labels */
--as-bg: #f8fafc; /* page background */
--as-card: #ffffff; /* card and status bar background */
--as-bg-alt: #f1f5f9; /* table header background */
--as-line: #64748b; /* table rules and stat card accent bars */
--as-card-line: #e2e8f0; /* decorative card outline */
--as-accent: #1550a0; /* links, skip link, ordinal badges */
--as-on-accent: #ffffff; /* text on the accent */
--as-pass: #14532d; /* Supports */
--as-warn: #7c2d12; /* Partially Supports */
--as-fail: #991b1b; /* Does Not Support */
--as-radius: 10px;
}node render.mjs status.json --css brand.css > accessibility.htmlIf you change these, check the result. Two things the defaults hold that a palette swap easily breaks:
- Every text colour clears 7:1 against both
--as-bgand--as-card. --as-lineclears 3:1, because it draws the table rules and the stat card accent bars, which WCAG 1.4.11 covers.--as-card-lineis decorative outline only and does not carry that requirement.
Status is colour-coded in the table, but the word is always printed, so nothing depends on colour alone.
It would be embarrassing otherwise. The generated page has a skip link to a
focusable main landmark, one h1 with no heading-level skips, a real table
with a caption and th scope on both axes, status conveyed as text rather than
by color alone, text contrast at 7:1 and above, and prefers-reduced-motion
respected.
node --testNo dependencies and no test framework to install. The suite runs the CLI as a subprocess, so it checks the real interface including exit codes.
Most of it guards the rules above rather than the rendering, because those rules
are the reason to use this over a form. There are cases asserting that AAA is
downgraded, that inapplicable and incomplete axe results never become
"Supports", that a reported failure survives an auditor writing "Does not
support" in different casing, that empty and unmeasured input are refused with a
non-zero exit, and that nothing in the output ever declares conformance. A
further group parses the palette out of the rendered CSS and checks the contrast
the README claims, so a reskin that drops text under 7:1 or the structural border
under 3:1 fails the build rather than shipping quietly.
It does not generate a VPAT or ACR. A procurement-grade conformance report is a different document with different stakes, and it is a commercial feature of AccessSpark rather than part of this project.
It also does not run an audit. It renders results you already have, so pair it with whatever testing you already do.
MIT. See LICENSE. Copyright (c) 2026 Inner Spark Media, LLC.
