Skip to content
Artemy-AndPublic

About

Self-hosted accessibility monitoring for site owners and agencies. Crawls your site, checks every page against WCAG with axe-core.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Tabwalk

See what's actually broken. On your own server.

Self-hosted accessibility monitoring. Tabwalk crawls your site every day or every week, checks each page with axe-core, presses Tab through it, collapses repeated problems into one row, shows what is new and what got fixed since the last scan, and tells Slack, Discord, ntfy or your inbox when something new breaks.

A Tabwalk report for a demo shop: 17 unique problems on 81 elements, 8 critical, 4 that need a human, filters for new and fixed problems, and the findings table

One command to install. No Redis — the job queue lives in the same Postgres.

Tabwalk collects evidence. It is not a legal opinion and it does not make your site compliant. Automated checks catch less than half of WCAG problems; the rest needs a human. We show you which parts those are.

Named after the tab walk — pressing Tab through a page to see whether every control can be reached and used without a mouse. It is the first thing an accessibility tester does by hand, and Tabwalk does it on every page it scans.

What it checks

Every page is opened in Chromium and checked twice:

  • axe-core runs the WCAG 2.2 A and AA rules against the markup.
  • The tab walk presses Tab and Shift+Tab through the page and follows the skip link, looking for what only shows up when you use the keyboard:
Rule WCAG What it finds
keyboard-trap 2.1.2 Focus that cannot leave a widget, a form or a frame
focus-visible 2.4.7 Elements that take focus while nothing changes on the screen
focus-obscured 2.4.11 Focused elements hidden under a sticky header, a cookie banner or other fixed content
skip-link-target 2.4.1 Skip links that leave focus where it was

A cookie banner or a pop-up that holds focus is closed the way a keyboard user would close it, with Enter on its accept or close button, and the walk goes on to the page behind it.

Every page also gets a picture of its Tab order: a screenshot with each stop outlined, numbered and joined to the next one, plus the same stops as a list. Open a page from the scan report to see it. Each problem in the report also gets a picture of the first element that has it, outlined, under Show the element; keyboard problems are pictured with the element focused. Pictures are kept for the latest scan of each site.

The Tab order of the demo shop's home page: 13 numbered stops joined by a line, from the logo and the menu through the product cards to the link in the footer

What a script cannot decide on its own, such as a loop that Shift+Tab can leave or a text field whose only sign of focus is the caret, goes to needs a human instead of the problem count.

Problems are WCAG 2.2 A/AA failures only. axe-core best-practice rules run too, but they are shown apart as recommendations: they are not counted as problems and never fail the GitHub Action.

Next to the WCAG criterion, each problem lists the matching clauses of EN 301 549 (the standard behind the European Accessibility Act), RGAA (France) and Section 508 (US federal sites), in the dashboard and in the CSV export.

For people who will not open the dashboard, PDF report on a scan gives a page made for printing: the summary, each problem with its picture, an example and how to fix it, then what needs a human, what was dismissed and the pages checked. Print it, or save it as a PDF from the browser's print dialog.

Quick start

git clone https://github.com/Artemy-And/tabwalk.git
cd tabwalk
cp .env.example .env      # Windows: copy .env.example .env
docker compose up -d

Open http://localhost:8080, create the admin account and add a site. It is scanned within 15 minutes and then weekly; pick daily or off on the site's page, or press Run a scan to check it right away.

The first person to open a new Tabwalk creates the admin account, so do it before the dashboard is reachable by anyone else. Lost the password? This prints a new one:

docker compose exec api node dist/reset-password.js you@example.com

To pin a version instead of latest, set TABWALK_VERSION=0.4.0 in .env.

Try it on the demo shop

examples/demo-site is a small shop with accessibility problems built in. Start it next to Tabwalk:

docker compose -f docker-compose.yml -f docker-compose.demo.yml up -d

Then add http://acmestore.example/ as a site and run a scan.

GitHub Action

Check a site on every push or pull request, without running the dashboard:

name: Accessibility
on: [pull_request]

jobs:
  tabwalk:
    runs-on: ubuntu-latest
    steps:
      - uses: Artemy-And/tabwalk@v0.4.0
        with:
          url: https://staging.example.com
          fail-on: serious
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: accessibility-report
          path: tabwalk-report.json
Input Default Meaning
url — Site to check. Tabwalk starts here, reads the sitemap and follows links from page to page
max-pages 50 Page cap
include — Check only pages under these paths, one per line, like /blog/ or /docs/*
exclude — Skip pages under these paths, one per line, like /tag/ or *?page=*
ignore-rules — Leave out these rules, one per line, like color-contrast
ignore-selectors — Leave out problems inside elements matching these CSS selectors, one per line, like #chat-widget
http-username, http-password — HTTP Basic login, as most staging sites have. Pass them from secrets
headers — Headers sent to the site only, one Name: value per line, like Authorization: Bearer …
cookies — Cookies set before the first page opens, one name=value per line
fail-on critical Lowest impact that fails the job: critical, serious, moderate, minor or none
baseline — A report from an earlier run. Only problems it does not have fail the job
comment false Comment on the pull request with the results
github-token github.token Token to comment with
report tabwalk-report.json JSON report path in the workspace

The job summary lists every problem; results that need a human are listed too but never fail the job.

Fail only on new problems

A site with problems already can still keep new ones out. Commit a report as the baseline, and the job fails only on problems the baseline does not have. The summary lists the new problems first, then the known ones and the ones no longer found. With comment: true the same summary goes to the pull request as one comment, edited on every run:

name: Accessibility
on: [pull_request]

permissions:
  contents: read
  pull-requests: write

jobs:
  tabwalk:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: Artemy-And/tabwalk@v0.4.0
        with:
          url: https://staging.example.com
          fail-on: serious
          baseline: .github/tabwalk-baseline.json
          comment: true

To make the baseline, run the check once and commit its tabwalk-report.json as .github/tabwalk-baseline.json; commit a newer report whenever you accept the problems it has. Until the file is there, every problem counts. Pull requests from forks get a read-only token, so they get the job summary but no comment.

The same check runs locally:

docker run --rm -v "$PWD:/out" -w /out --user root \
  --entrypoint node ghcr.io/artemy-and/tabwalk-server \
  /app/apps/server/dist/ci.js https://example.com --fail-on serious

Development with Docker

Builds the images from source:

docker compose -f docker-compose.dev.yml up -d --build

The first build takes a while — it downloads Chromium.

Development without Docker

Requires Node.js 22+, pnpm and a running Postgres.

pnpm install
pnpm --filter @tabwalk/server exec playwright install chromium

cp .env.example .env
# point DATABASE_URL at localhost instead of db

pnpm db:migrate

pnpm --filter @tabwalk/server dev          # API on :3000
pnpm --filter @tabwalk/server dev:worker   # scan worker
pnpm --filter @tabwalk/web dev             # UI on :5173

Try the scanner without a database

cd apps/server
npx tsx src/smoke.ts https://example.com

Prints findings with their fingerprints. Handy while working on checkers.

How it works

                 ┌──────────┐
   browser ────► │   web    │  nginx: static files + /api proxy
                 └────┬─────┘
                      │
                 ┌────▼─────┐        ┌────────────┐
                 │   api    │───────►│  Postgres  │◄──┐
                 │  (Hono)  │        │  data +    │   │
                 └──────────┘        │  job queue │   │
                                     └────────────┘   │
                 ┌──────────┐                         │
                 │  worker  │─────────────────────────┘
                 │  Playwright + axe-core
                 └──────────┘

api and worker are the same image with different commands. The worker also runs the schedule: every 15 minutes it queues a scan for each site that is due.

Design decisions

Decision Why
Postgres, not MongoDB One store for data and queue, real joins for reports
pg-boss, not BullMQ No Redis: one container fewer, one failure mode fewer
Schedules in pg-boss too No cron container; the schedule lives in the same Postgres
Checkers behind a Checker interface Trackers and PCI checks plug in without a storage rewrite
axe-core hidden behind that interface The engine belongs to a competitor (Deque); don't hard-wire it
Organizations in the schema from day one Retrofitting multi-tenancy means rewriting every query
Finding fingerprints One template mistake on 500 pages is one row, not 500
incomplete results are stored Competitors hide this bucket; it becomes the manual-review checklist

Layout

apps/server/src/
  api/app.ts            Hono routes
  auth/                 sign-in, sessions and password hashing
  db/schema.ts          Drizzle tables
  notify/               Slack, Discord, ntfy, webhook and email messages
  queue/boss.ts         pg-boss setup
  queue/schedule.ts     daily and weekly scans
  scanner/
    types.ts            Checker interface — the extension point
    crawl.ts            sitemaps from robots.txt, then links on every page it opens
    fingerprint.ts      collapses repeated findings
    checkers/
      axe.ts            the axe-core checker
      keyboard.ts       the tab walk: traps, invisible and hidden focus
      keyboard-page.ts  its helpers that run inside the page
    check.ts            opens one page and runs every checker
    runner.ts           orchestrates one scan
  ci.ts                 command-line check used by the GitHub Action
apps/web/src/
  router.tsx            pages and routes
  components/           accessible UI primitives

Configuration

All of it lives in .env:

Variable Default Meaning
MAX_PAGES_PER_SCAN 50 Page cap per scan. A site can set a lower one on its page
SCAN_CONCURRENCY 3 Tabs at once. Each costs 300–500 MB
PAGE_TIMEOUT_MS 30000 Per-page load timeout
CHROMIUM_EXECUTABLE — Your own Chromium, if the bundled one won't start
PUBLIC_URL — The dashboard's address, e.g. https://a11y.example.com. Makes the sign-in cookie Secure

Which pages are checked

Tabwalk starts at the site's address, reads the sitemaps listed in robots.txt (or /sitemap.xml), then follows the links on every page it opens until it reaches the page cap. Links are read after the page's scripts have run, so a single-page app without a sitemap is checked page by page. Links to files such as PDFs and images are skipped.

On a site's page in the dashboard, Pages to check narrows the crawl with paths, one per line. * stands for anything, and the site's own address is always checked:

Field Example Effect
Only check these paths /blog/ Only addresses that start with /blog/
Skip these paths /tag/ and *?page=* No tag pages and no paginated lists

Ignoring problems

For code you can't change, like a chat widget from another company, a site's page has Problems to ignore: rule IDs (color-contrast) and CSS selectors (#chat-widget). A scan leaves those problems out and doesn't store them, and its report says what it left out. The GitHub Action takes the same lists as ignore-rules and ignore-selectors.

Dismissing a finding

In a report, Dismiss under a finding marks it as a false positive or as won't fix, with an optional note. It stays in the data and is listed under Dismissed with who dismissed it and when, but it no longer counts for any scan of the site: not in totals, trends, comparisons or notifications. Reopen brings it back.

Pages behind a login

Signing in on a site's page takes an HTTP Basic user and password, headers such as Authorization: Bearer …, and cookies copied from a browser. They go to the site's own address only, never to scripts or images from other addresses. Tabwalk keeps them in its Postgres as entered, like notification webhooks, and never shows them in the dashboard again, so use an account that can only read. A page that answers HTTP 401 is reported as needing a login instead of being checked. Logging in through a form is not supported yet.

Notifications

Under Settings, add a Slack, Discord or ntfy channel, a webhook that gets JSON, or an email address. Tabwalk sends a message when a scan finds new problems, when a site gets its first scan and when a scan fails. Set PUBLIC_URL so every message links to its report. Email goes out through your own SMTP server:

SMTP_URL=smtps://user:password@smtp.example.com:465
SMTP_FROM=Tabwalk <tabwalk@example.com>

Single sign-on

Google Workspace, Microsoft Entra ID, Keycloak, Authentik or any other OpenID Connect provider can sign people in next to the password:

PUBLIC_URL=https://a11y.example.com
OIDC_ISSUER=https://accounts.google.com
OIDC_CLIENT_ID=...
OIDC_CLIENT_SECRET=...
OIDC_LABEL=Sign in with Google
OIDC_ALLOWED_DOMAINS=example.com

Register https://a11y.example.com/api/auth/oidc/callback as the redirect URI. People from OIDC_ALLOWED_DOMAINS get an account on their first sign-in; anyone else needs an account with the same email first.

Accessibility of Tabwalk itself

An accessibility tool has to pass its own check. Two failures that a 2026 audit found to be common in dashboards are handled here deliberately:

  • Focus ring. Popular component libraries ship a default that fails the 3:1 contrast requirement. Tabwalk defines its own in index.css, verified in both light and dark themes.
  • Data table. The most common dashboard failure: no caption, no aria-sort, sorting never announced. IssuesTable.tsx handles all three.

Run Tabwalk against its own dashboard before every release.

Roadmap

Phase 2 adds the AI layer: plain-language reports, the incomplete bucket turned into a manual-review checklist, alt-text judged by a vision model, and suggested code fixes (suggested — never applied automatically).

Known MVP gaps: one organization where every account sees every site, and no login through a form: pages behind a login need HTTP Basic, a header or a cookie.

Community

License

AGPL-3.0. Use it, self-host it and change it freely; if you offer a modified Tabwalk to others over a network, publish your changes under the same license.

About

Self-hosted accessibility monitoring for site owners and agencies. Crawls your site, checks every page against WCAG with axe-core.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages