Command-line WCAG 2.2 accessibility scanner for static HTML, or rendered pages with --browser — several pages per run, baseline and severity gating, HTML, SARIF and JUnit reports, custom rules. Powered by @surea11y/core, the deterministic engine that tells you what it can't tell you.
npx @surea11y/cli scan ./index.html
npx @surea11y/cli scan https://example.com/ --wcag 2.2
npx @surea11y/cli scan ./dist/index.html ./dist/pricing.html --fail-on-severity serious
npx @surea11y/cli scan https://app.example.com/ --browsernpm install -g @surea11y/cli # globally
npm install --save-dev @surea11y/cli # or per-project, for CIRequires Node.js ^20.19.0 || ^22.13.0 || >=24.0.0.
$ surea11y scan ./index.html
surea11y scan: file:///path/to/index.html
engine: @surea11y/core 1.14.0
pass: 16 fail: 4 cantTell: 4 notApplicable: 121
occurrences by tier: fail: 4 cantTell: 4
needs review: likely fail: 1 likely pass: 0 needs a person: 3
FAIL (4 rule(s)):
button-name-present (serious, 1 fail occurrence(s))
- html > body > main > button
This button has no accessible name.
hint: Give the button visible text, or name it with aria-label, so screen
reader users know what it does.
contrast-enhanced (serious, 1 fail occurrence(s))
- html > body > main > p
Element has insufficient color contrast (AAA) of 2.85:1 (foreground:
#999999, background: #ffffff, font size: 16px, font weight: normal).
Expected contrast ratio of 7:1 (normal text).
hint: Change the text color, the background color, or both, so the
contrast ratio reaches at least 7:1.
contrast-minimum (serious, 1 fail occurrence(s))
- html > body > main > p
Element has insufficient color contrast of 2.85:1 (foreground: #999999,
background: #ffffff, font size: 16px, font weight: normal). Expected
contrast ratio of 4.5:1 (normal text).
hint: Change the text color, the background color, or both, so the
contrast ratio reaches at least 4.5:1.
img-alt-present (serious, 1 fail occurrence(s))
- html > body > main > img
This <img> has no alt attribute.
hint: Add an alt attribute that says what the image shows, or alt="" if
the image is purely decorative.
LIKELY FAIL — confirm (1 occurrence(s)):
- heading-order (minor): html > body > main > h3
This heading jumps from level 1 to level 3, skipping a level.
why: The heading skips a level after the one before it.
cantTell — needs human review (4 rule(s)): heading-order, link-name-quality,
manual-review, page-title-patterns
criteria (WCAG 2.2):
fail 1.1.1 Non-text content: text alternatives; 1.4.3 Contrast: minimum;
1.4.6 Contrast: enhanced; 4.1.2 Name, role, value: accessible name
cantTell 1.2.1 Audio-only and video-only (prerecorded): transcript; 1.2.2
Captions (Prerecorded); 1.3.1 Info and Relationships; 1.3.4
Orientation; 1.3.5 Identify Input Purpose; 1.4.1 Use of Color;
1.4.2 Audio Control; 1.4.4 Resize Text; 1.4.5 Images of Text;
1.4.12 Text Spacing; 2.1.1 Keyboard; 2.1.3 Keyboard (No Exception);
2.2.1 Timing Adjustable; 2.2.2 Pause, Stop, Hide; 2.2.4
Interruptions; 2.4.1 Bypass Blocks; 2.4.2 Page titled; 2.4.3 Focus
order; 2.4.4 Link Purpose (In Context); 2.4.6 Headings and Labels;
2.4.7 Focus visible; 2.4.9 Link Purpose (Link Only); 2.5.8 Target
size: minimum; 3.1.1 Language of page; 3.1.2 Language of Parts;
3.2.5 Change on Request; 3.3.1 Error Identification; 3.3.2 Labels
or Instructions; 3.3.8 Accessible Authentication (Minimum); 4.1.2
Name, role, value: ARIA validity; 4.1.3 Status Messages
notApplicable: 2
A criterion that passes here means the checks that ran found nothing in it,
not that the page conforms.
The criteria block reads the same scan by WCAG criterion, the way people usually ask ("does it fail 1.4.3?"). A criterion that only some of its checks decided, because the scan selected the others out (--rules, --tags), is marked (partly checked: 1 of 2 checks not selected). --compact shows the block as counts. A passing criterion means the checks that ran found nothing, not that the page meets it: most criteria need a person to check them.
That last line is the point of the engine: rules that cannot be settled from static markup are reported as cantTell and routed to a human, instead of being silently dropped or guessed at. With @surea11y/core 1.14 no criterion is listed as passing yet: one passes only when its rules check all it asks, so where they all pass it is left for a person to check the rest.
The questions say more than that. The summary counts them by what a reviewer does with them: likely fail (the page's evidence points to a failure: the heading above skips a level), likely pass, and needs a person, and lists the likely failures with the reason each leans that way. Every question in --json and in the reports carries the steps to answer it (howToCheck). cantTell doesn't affect the exit code, unless you pass --fail-on-lean, which fails the run on the likely failures too. See Questions that likely fail.
Exit code 0 = clean, 1 = at least one fail on some page (the CI-gating case; --fail-on-severity serious counts only serious and critical ones; --fail-on-lean adds the likely failures), 2 = usage error or the scan couldn't run, including a scan that would have checked nothing or less than asked (a --rules/--tags typo, a --context selector that matches nothing, a custom rule or a pack that didn't run, a --profile that wasn't applied). --profile can be repeated to judge a page by several profiles at once.
Several pages. Name them, or list them in a file, one per line:
surea11y scan ./dist/index.html ./dist/pricing.html
surea11y scan --urls pages.txt
surea11y scan --sitemap https://example.com/sitemap.xml # up to 50 pages; --max-pages to change it# pages.txt
./dist/index.html
./dist/pricing.html # the plans table
https://staging.example.com/account
Each page gets its own section of the summary, and a last line counts the pages with failures. The run fails when any page does. --json prints an array, --sarif and --junit write one file covering every page, --html writes one report per page, and a baseline remembers which page each known failure was on.
A severity gate. Every failure is still reported; only those at the level or above fail the run:
$ surea11y scan --urls pages.txt --fail-on-severity serious
...
FAIL (2 rule(s)):
img-alt-present (serious, 1 fail occurrence(s))
- html > body > main > img
This <img> has no alt attribute.
hint: Add an alt attribute that says what the image shows, or alt="" if
the image is purely decorative.
empty-heading (minor, 1 fail occurrence(s); below --fail-on-severity serious,
does not fail the run)
- html > body > main > h2
This heading has no accessible name.
...
2 pages scanned: 1 with failures at serious or above.
Failing rules are listed most severe first. With --baseline, only new failures at the level count, so a team can start by blocking on serious problems and tighten the gate as the backlog shrinks.
A real browser. For a page whose content its JavaScript renders, install Playwright in your project and add --browser:
npm install --save-dev playwright
npx playwright install chromium
surea11y scan https://app.example.com/ https://app.example.com/settings --browserEach page opens in headless Chromium, its scripts run, and the scan sees what renders, with a real layout for the rules that measure it. The engine is the same @surea11y/core the static scan uses. Every option works with it, reports, baselines, packs and custom rules included.
Before each page loads, the CLI installs core's internals and listener hooks, so the scan also sees what a page does from script: click handlers added with addEventListener (a role="button" that Enter and Space never reach fails) and names that components set through ElementInternals. --live-regions adds the live-region hook, for a page that shows a status message by itself after loading: one added with its text already in it, which screen readers commonly don't announce, is asked about. --no-hooks scans without the first two. See Hooks.
An app that fetches its data after loading can make the scan wait for it, and the browser can be narrowed to a phone's width or switched to dark mode:
# Wait until the orders table is shown (up to 30 s, or --wait-timeout ms)
surea11y scan https://app.example.com/orders --browser --wait-for "#orders-table"
# A phone-sized screen in dark mode: the layout rules judge the page as drawn there
surea11y scan https://example.com/ --browser --width 320 --color-scheme darkThe summary names the viewport and color scheme each scan used, and the hooks it installed before the page's scripts so web components' names and listeners added from script are judged too (--live-regions adds the live-region hook; --no-hooks leaves them out). Several values, --width 1280,320 --color-scheme light,dark, scan every combination and report one merged result per page. See Waiting for content and Width and color scheme.
By default the CLI reads static HTML — a local file, or the raw body of an HTTP(S) GET — and never executes page JavaScript. Client-rendered content won't be captured, and geometry-dependent rules report notApplicable (there's no real CSS layout under jsdom).
With --browser (and Playwright installed in your project), it opens each page in headless Chromium, lets its scripts run, and scans what renders, with the same @surea11y/core. For content that only appears after a click or a login, drive the browser yourself with one of the bindings (Playwright, Puppeteer, Cypress, Selenium, WebdriverIO). See docs/CLI.md.
# Target a WCAG version and level (AA unless --wcag-level says otherwise)
surea11y scan ./dist/index.html --wcag 2.2
# A whole site in one run, failing only on serious and critical problems
surea11y scan --urls pages.txt --fail-on-severity serious
# A client-rendered app, as its scripts render it (needs playwright installed)
surea11y scan https://app.example.com/ --browser
# ...waiting for data it loads after the page, and on a phone in dark mode
surea11y scan https://app.example.com/orders --browser --wait-for "#orders-table"
surea11y scan https://example.com/ --browser --width 320 --color-scheme dark
# The same, and the best-practice rules, which test no WCAG criterion
surea11y scan ./dist/index.html --wcag 2.2 --best-practices
# A design-system team's forms check: every forms rule, plus the contrast check
surea11y scan ./dist/form.html --tags forms --rules contrast-computable --include-mode or
# One check, judged at AA: reports 1.4.3, not the AAA 1.4.6 the same check covers
surea11y scan ./dist/index.html --rules contrast-computable --wcag-level AA
# Machine-readable output, in full or compact
surea11y scan ./dist/index.html --json > result.json
surea11y scan ./dist/index.html --compact --json > result.json
# Accept today's violations, gate CI only on new ones
surea11y scan ./dist/index.html --write-baseline baseline.json # once, commit it
surea11y scan ./dist/index.html --baseline baseline.json # in CI, from then on
# Also fail on the questions that likely fail, WCAG rules only, and accept today's
surea11y scan ./dist/index.html --fail-on-lean --lean-fail-wcag-only
surea11y scan ./dist/index.html --fail-on-lean --write-baseline baseline.json
surea11y scan ./dist/index.html --fail-on-lean --baseline baseline.json
# Bring a baseline written by an older core up to its reason codes
surea11y scan ./dist/index.html --migrate-baseline baseline.json
# Browsable HTML report, and SARIF for GitHub Code Scanning
surea11y scan ./dist/index.html --html report.html --sarif results.sarif
# Several pages: one SARIF and one JUnit file, an HTML report per page
surea11y scan --urls pages.txt --sarif results.sarif --junit a11y.junit.xml --html report.html
# JUnit XML for the test views of GitLab, Azure DevOps, Jenkins and CircleCI
surea11y scan ./dist/index.html --junit a11y.junit.xml
# Your own org-specific rules
surea11y scan ./dist/index.html --custom-rules ./a11y-rules.js
# The checks the scan leaves to a person, as a worksheet next to the report
surea11y scan https://example.com/ --wcag 2.2 --html report.html --manual-checks manual-checks.html
# How to assess every WCAG 2.2 A and AA criterion by hand, or a worksheet for a manual audit
surea11y procedures --wcag-level AA -o wcag-22-aa.html
surea11y procedures --wcag-level AA --checklist --title "Audit: example.com" -o audit.html
# A standard or your organisation's checklist, from a pack, with its profile
surea11y scan ./dist/index.html --pack ./acme-policy-pack.js --profile acme-web-2026Full flag reference, custom-rule contract, packs, and CI recipes: docs/CLI.md. Its Packs section has acme-policy-pack.js, a small pack to copy, with a page and the command that scans it.
You need it when you want a command instead of code. Two examples.
A marketing site built to static HTML. The CI step is one shell line; there's no test file anywhere.
# .github/workflows/a11y.yml
- run: npm run build
- run: npx @surea11y/cli scan ./dist/index.html --wcag 2.2The scan exits 1 if anything fails, which fails the step. You wrote no JavaScript, so you needed the command.
The same goes for a single-page app whose pages its JavaScript renders, as long as nobody has to click to get there:
# playwright is in the project's devDependencies; a11y-pages.txt lists
# http://localhost:4173/, http://localhost:4173/settings, ...
- run: npm ci && npx playwright install --with-deps chromium
- run: npm run build
- run: npx serve -s dist -l 4173 & npx wait-on http://localhost:4173
- run: npx @surea11y/cli scan --urls a11y-pages.txt --browser --fail-on-severity serious --sarif a11y.sarifA dashboard whose chart only exists after you click "Load report". You're already writing a Playwright test, so call the engine directly:
const { A11yCoreBuilder } = require('@surea11y/playwright');
test('dashboard is accessible after loading', async ({ page }) => {
await page.goto('/dashboard');
await page.getByRole('button', { name: 'Load report' }).click();
const results = await new A11yCoreBuilder({ page }).analyze();
expect(results.checksResults.filter((r) => r.outcome === 'fail')).toEqual([]);
});No @surea11y/cli here. This is also CI — but the CLI can't click. Even with --browser, surea11y scan sees the page as it loads, before the click ever happens: no chart, nothing to check.
It isn't local vs. CI — both examples are CI. It's:
- Is what you want to scan present in the HTML the server sends? The CLI works, and it's the cheap path: no browser to launch.
- Does it appear once the page's JavaScript has run, with no one touching it? The CLI with
--browserworks: a client-rendered app's pages, scanned as they load. - Does it only appear after someone clicks, types or logs in? You need to drive the browser, so you need the library or a binding.
That last case is why "use the CLI for pipelines" is a misleading shorthand — plenty of pipelines are the dashboard example.
Either way you're running the same engine. @surea11y/core has zero runtime dependencies and evaluates whatever DOM you hand it, so if you already have a browser or a jsdom instance, you don't pay for jsdom twice. This package is the one that brings jsdom along, because turning fetched HTML into a DOM is what a CLI has to do. Splitting the two is what keeps that promise true.
One engine, several front ends. If the dividing line above pointed you away from
the CLI, install the one matching your setup instead — each pulls in
@surea11y/core for you.
| Your setup | Install |
|---|---|
| Playwright | @surea11y/playwright |
| Puppeteer | @surea11y/puppeteer |
| Selenium | @surea11y/selenium |
| Cypress | @surea11y/cypress |
| WebdriverIO | @surea11y/webdriverio |
| Jest or Vitest component tests | @surea11y/test-matchers |
| Your own script, against a DOM you already have | @surea11y/core |
npm install
npm testTo run against a local, unpublished @surea11y/core — when you're changing the
engine and the CLI together — pack it and install it without saving, so
package.json keeps declaring its normal semver range:
(cd ../core && npm pack --pack-destination /tmp)
npm install /tmp/surea11y-core-<version>.tgz --no-save --no-package-lock
npm testNever commit a file: dependency. Note that npm link is not a substitute
here: it exposes the whole working directory and so ignores core's files
allowlist, which can resolve paths that won't exist in the published package.
See RELEASE.md for the publish checklist and the cross-repo
ordering constraint.
MIT. The engine, @surea11y/core, is MPL-2.0.