Since the European Accessibility Act started applying in June 2025, a lot of shops, agencies and small product teams have the same question: where do our sites actually stand, and is it getting better or worse?
The tools for that tend to fall into two groups. Enterprise monitors do the whole job, at enterprise prices, with your data on their servers. Open-source scanners are free, but they either run once, or keep a history only for the URLs you add one by one.
I wanted checking a site to be quick and easy, with a report that's clear and honest about what it covers. And I wanted it free, on my own server. So I built Tabwalk.
TL;DR: Tabwalk scans whole sites daily or weekly with Playwright and axe-core, turns one template bug into one row instead of hundreds, and runs on your own server with one docker compose up -d. Free and AGPL-3.0.
What it does
-
Finds pages. Tabwalk reads
sitemap.xml(orsitemap_index.xml). With no sitemap, it takes the links from the home page. Tracking parameters likeutm_*are stripped so the same page isn't checked twice. - Checks each page. A worker opens it in headless Chromium through Playwright and runs axe-core with the WCAG 2.2 A/AA rules.
- Does it again. Every site is rescanned daily or weekly, and each scan is compared with the previous one: what's new, what got fixed, and how the count trends.
One mistake counts once
A template bug shows up on every page that uses the template. If a scanner reports it 500 times, nobody reads the report.
So Tabwalk fingerprints every finding. It takes the element's markup, blanks out everything that changes from page to page, and hashes the result together with the rule id:
export function fingerprint(ruleId: string, html: string): string {
const normalized = html
.replace(/\s+/g, ' ')
.replace(
/\b(id|for|href|src|srcset|value|aria-labelledby|aria-describedby|aria-controls|data-[\w-]+)="[^"]*"/gi,
'$1="*"',
)
.replace(/>[^<>]{1,400}</g, '><')
.trim()
.slice(0, 800);
return createHash('sha256').update(`${ruleId}|${normalized}`).digest('hex').slice(0, 16);
}
Two product cards with an empty link, <a href="/p/1042" class="card-link"> and <a href="/p/2077" class="card-link">, both become <a href="*" class="card-link">. Same rule, same fingerprint, one row with a page count.
In the demo shop that ships with the repo, that turns 66 failing elements into 13 rows.
It says what it can't judge
axe-core returns a third category next to passes and violations: incomplete. These are checks it can't decide on its own, like contrast over a background image.
Tabwalk keeps them and labels them "needs a human". Automated checks cover only part of WCAG, so the honest thing for a tool to do is say which parts it measured and which parts are yours.
No Redis, no cron container
Scans are background jobs, and the usual answer is Redis + BullMQ, plus cron for the schedule. Tabwalk uses pg-boss for both: the queue and the schedule live in the same Postgres as the data. Every 15 minutes the worker queues a scan for each site that is due.
The whole thing is four containers: Postgres, API (Hono), worker and web (nginx serving the React dashboard):
git clone https://github.com/Artemy-And/tabwalk.git
cd tabwalk
cp .env.example .env
docker compose up -d
Because it runs on your network, it can check staging and internal sites a cloud service can't reach. There's no account and no telemetry; results stay in your Postgres.
In CI
The same checker runs as a GitHub Action, without the dashboard:
- uses: Artemy-And/tabwalk@v0.2.1
with:
url: https://staging.example.com
fail-on: serious
The job summary lists every problem. "Needs a human" results are listed too, but they never fail the build.
Try it without a real site
The repo includes a small demo shop with problems built in: images without alt text, icon buttons without names, a heading jump, unlabeled form fields, a video without captions.
docker compose -f docker-compose.yml -f docker-compose.demo.yml up -d
Add http://acmestore.example/ as a site and run a scan.
What it doesn't do
It doesn't make a site compliant, and it doesn't change anything on it: no overlay, no widget. There's no login yet, pages behind a login aren't scanned, and reports can't be exported yet.
Next up: plain-language explanations, turning the "needs a human" results into a checklist, and suggested code fixes. Suggested, never applied automatically.
Tabwalk is free and AGPL-3.0. If you try it, I'd love to hear what's missing. Issues, discussions and pull requests are open, and there are a few good first issues if you'd like to help. And if it's useful to you, a star on GitHub helps other people find it.

Top comments (2)
For icon-only buttons with inline SVG, do you normalize data before fingerprinting? Otherwise the same missing-name bug could look different for every icon glyph.
Good catch. Partly, and not on purpose. The normalizer wildcards ids, hrefs, data-* and ARIA references and drops text nodes, but it doesn't touch SVG internals. axe-core helps by accident: if an element's outerHTML is over 300 chars, it only reports the opening tag, so buttons with long icon paths already collapse. Short icons like Lucide's x or chevron fit under that limit, their
<path d>goes into the hash, and you get one row per glyph. I'll collapse<svg>…</svg>to a bare<svg>before hashing, so the fingerprint depends on the button's structure and not on which icon it draws. Thanks, that's exactly the kind of case I'd miss.