Automate Accessibility Checks in CI
You know that automated tools exist. Now let’s wire some of them into our development workflow so every push and every pull request gets checked automatically — no manual clicks, no forgotten scans.
We will look into 2 levels of automated checks:
- HTML validation on every push (pre-push hook)
- WCAG 2.2 AA accessibility checks on every pull request (GitHub Actions)
Both running against our actual built pages.
This guide uses an Astro project with pnpm as an example, but html-validate and pa11y-ci work with any static site or framework. Adapt the build and serve commands to your stack.
Install the tools
You need 3 packages:
- html-validate — lints built HTML files for structural issues, invalid ARIA, and spec violations
- pa11y-ci — runs WCAG (Web Content Accessibility Guidelines) accessibility checks against rendered pages using a headless browser
- husky — manages git hooks (pre-push, pre-commit)
pnpm add -D html-validate pa11y-ci husky
pa11y-ci needs a running server to check pages. Since Astro has a built-in preview server (astro preview), we use that. Adapt the serve command to your framework’s preview server.
Initialize husky:
npx husky init
This creates a .husky/ directory and adds a prepare script to your package.json.
Configure html-validate
Create .htmlvalidate.json in your project root:
{
"extends": ["html-validate:recommended"],
"rules": {
"no-inline-style": "off",
"no-trailing-whitespace": "off",
"long-title": "warn",
"void-style": "off",
"no-raw-characters": "off",
"attr-case": ["error", { "ignoreForeign": true }]
}
} Why these overrides?
| Rule | Why off/warn |
|---|---|
no-inline-style | Syntax highlighting (Shiki, Prism) generates inline styles in code blocks |
no-trailing-whitespace | Build tools and formatters may leave trailing spaces — not worth blocking on |
long-title | Some page titles exceed the recommended length — warn instead of error |
void-style | MDX renderers may output <hr/> instead of <hr> — both are valid |
no-raw-characters | Markdown-rendered content may contain > in text — valid in HTML |
attr-case | SVG attributes like viewBox use camelCase — ignoreForeign allows this |
Run it against your build output:
pnpm build
npx html-validate 'dist/**/*.html'
When you first run html-validate, expect it to find real issues — like aria-label on elements that don’t support it, missing type on buttons, or invalid nesting. Fix these before configuring rules to suppress them.
Configure pa11y-ci
Create .pa11yci.json in your project root:
{
"defaults": {
"standard": "WCAG2AA",
"timeout": 10000
},
"sitemap": "http://localhost:4321/sitemap-0.xml",
"sitemapFind": "https://your-domain.com",
"sitemapReplace": "http://localhost:4321"
} Instead of listing URLs manually, pa11y-ci reads your sitemap and discovers all pages automatically. sitemapFind and sitemapReplace rewrite the production URLs to point at your local server.
WCAG2AA is the standard most accessibility laws require (EU’s EN 301 549, US Section 508). Change to WCAG2AAA if you want stricter checks.
To test locally:
pnpm build
pnpm preview & npx pa11y-ci
To check a single page during development, you can run pa11y directly without the CI config:
npx pa11y http://localhost:4321/blog/your-page/
Set up the hook to validate HTML on every push
Create .husky/pre-push:
pnpm build && npx html-validate 'dist/**/*.html' Every git push now builds your site and validates the HTML. If validation fails, the push is blocked.
Why only html-validate in the hook?
pa11y-ci needs to start a server, launch a headless browser, and crawl every page — noticeably slower than html-validate, which just reads files from disk. Running the full a11y check in CI is my personal preference — you can also add it to the pre-push hook. Either way, keep it in CI — hooks can be skipped, CI is the last line of defense before code gets merged.
Create the GitHub Actions workflow
Create .github/workflows/validate.yml:
name: HTML & Accessibility Validation
on:
pull_request:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- name: Build website
run: pnpm build
- name: HTML validation
run: pnpm validate:html
- name: Start server & run a11y checks
run: |
# Override pa11y-ci config to use system Chrome
cat > .pa11yci.json <<EOF
{
"defaults": {
"standard": "WCAG2AA",
"timeout": 10000,
"chromeLaunchConfig": {
"executablePath": "/usr/bin/google-chrome-stable",
"args": ["--no-sandbox"]
}
},
"sitemap": "http://localhost:4321/sitemap-0.xml",
"sitemapFind": "https://your-domain.com",
"sitemapReplace": "http://localhost:4321"
}
EOF
pnpm preview &
SERVER_PID=$!
# Wait for server to be ready
for i in $(seq 1 10); do
curl -s http://localhost:4321/ > /dev/null && break
sleep 1
done
pnpm validate:a11y
EXIT_CODE=$?
kill $SERVER_PID
exit $EXIT_CODE What this does:
- Builds the website
- Runs html-validate against all HTML files
- Overrides the pa11y-ci config to use system Chrome (needed in CI)
- Starts the preview server, waits for it, runs pa11y-ci against all pages from the sitemap, then cleans up
Add the npm scripts to your package.json:
{
"scripts": {
"validate:html": "html-validate 'dist/**/*.html'",
"validate:a11y": "pa11y-ci"
}
}
What you now have
- html-validate catches structural HTML issues on every
git push(pre-push hook) - pa11y-ci runs WCAG 2.2 AA checks against every page on every pull request (GitHub Actions)
- Both run against your actual built pages, not source code — so they catch what users will see
Remember: This won’t replace manual testing with keyboard and assistive technologies. But they catch them consistently, on every change, without anyone needing to remember.
FAQ
Can I use axe-core instead of pa11y-ci?
Yes. @axe-core/cli is a good alternative. pa11y-ci has stronger multi-page CI support out of the box, including URL-list and sitemap-based runs with CI-friendly failure behavior, while axe-core is the widely adopted accessibility engine behind Deque’s axe tooling and influences Lighthouse accessibility scoring.
What about Lighthouse CI?
Lighthouse CI is strong when you want Lighthouse’s broader audit coverage — performance, SEO, best practices, and accessibility — in one CI workflow. It can be more involved than an accessibility-only runner, especially if you want uploaded reports, historical tracking, or GitHub status checks, but those are optional. For accessibility-only checks, pa11y-ci is typically simpler.
See also:
- Run Automated Accessibility Tools — overview of browser extensions and manual tools
- Validate Your HTML — why valid markup matters
- Practical accessibility testing — the full testing process