
Treat accessibility like GDPR: periodic WCAG audits, accessibility statements and inclusive design
Run accessibility like privacy: roles, WCAG-EM audits, a testing cadence, CI with a baseline, production monitoring, accessibility statements and vendors.
In 2018 GDPR taught European companies an uncomfortable lesson: compliance isn’t a document, it’s a process. A well-written privacy policy isn’t enough. You need a record of processing activities, someone accountable, impact assessments before launching something new, contracts with vendors and a way to react when something goes wrong.
With the European Accessibility Act, accessibility is going down the same road. In the practical guide to the EAA and WCAG 2.2 I covered who has to comply and how to write accessible code: semantic HTML, keyboard, contrast, forms, testing. This post tackles the other half of the problem, the one that decides whether a site that complies today will still comply six months from now: how to organize accessibility so it holds up over time.
The GDPR parallel
The comparison is more than a metaphor. Almost every part of a privacy program has an accessibility counterpart, and if your company has already done the GDPR work, half the structure is already in place.
| GDPR | Accessibility |
|---|---|
| Privacy by design (Art. 25) | Accessibility by design: requirements in tickets and in the design system |
| Record of processing activities (Art. 30) | Inventory of pages, flows and components with their conformance status |
| DPIA before new processing (Art. 35) | Accessibility review before shipping a new feature |
| DPO or privacy lead | Accessibility lead with real authority over the backlog |
| Privacy policy | Accessibility statement or accessibility information |
| Processor contracts (Art. 28) | EN 301 549 requirements in contracts with vendors and third-party widgets |
| Data breach handling (Art. 33) | A reporting channel and response times for barriers |
| Data protection authority | The national market surveillance authority (AgID in Italy, for EAA services) |
One important difference: the EAA doesn’t require a private company to appoint an “accessibility officer” the way GDPR requires a DPO in some cases. In practice, though, without someone accountable, accessibility always ends up at the bottom of the backlog.
ℹ️ Nota
For Italian public bodies the process is already codified by the Stanca Law and AgID’s guidelines: accessibility objectives published by March 31 and an accessibility statement filed every year by September 23 through AgID’s form. Other EU countries have similar rules for the public sector under the Web Accessibility Directive. It’s a good model for private companies too: their obligations are less formal, but the continuity problem is the same.
Roles: who does what
Accessibility is not a developer problem. Many barriers are created before the code (a design with no focus states, a flow that asks for the same data twice) or after it (an editor uploading an infographic with no text alternative).
| Role | Responsibility |
|---|---|
| Accessibility lead | Keeps the inventory, schedules audits, sets priorities with the product owner, updates the statement |
| Product owner | Puts accessibility criteria in requirements; a blocking barrier is a bug, not a feature |
| Design | Verified palette, focus and error states, reading order and target sizes in mockups |
| Development | Accessible components, automated tests in CI, no regressions |
| QA | Manual keyboard and screen reader tests on critical flows for every release |
| Content | Text alternatives, structured headings, plain language, captions and transcripts |
| Procurement and legal | Requirements in contracts, conformance reports requested from vendors |
Audits: a method, not a Lighthouse run
A serious audit needs a repeatable method, otherwise two audits six months apart can’t be compared. The reference is the W3C’s WCAG-EM (Website Accessibility Conformance Evaluation Methodology), which splits the work into five steps:
- Define the scope: which site or app, which WCAG version (today 2.2 AA), which browsers and assistive technologies.
- Explore the website: identify common pages (home, search, contact), essential functionality (purchase, sign-up, login), page types and the technologies in use.
- Select a representative sample: a structured sample with one example of every template and every flow, plus a random sample of at least 10% of the structured sample. The random one is there to find what you didn’t expect.
- Audit the sample: every WCAG criterion on every page, with automated tools and manual checks.
- Report the findings: criterion by criterion, with where the problem is, its impact and how to fix it.
For flows, the sample isn’t individual pages but complete processes: a checkout conforms only if every step does. A perfect payment page after a cart you can’t use with a keyboard helps no one.
Write up the audit in a format others can read. For software products the de facto standard is the Accessibility Conformance Report (ACR), filled in using ITI’s VPAT template, which has an EU edition based on EN 301 549. If you sell software to other businesses, sooner or later someone will ask for one.
The cadence: when to check what
GDPR doesn’t ask you to redo a DPIA every week, but it does ask you to redo it when something significant changes. Accessibility works the same way: light, frequent checks and deep, infrequent ones.
| When | What | Who |
|---|---|---|
| Every pull request | axe in CI on the components and pages touched, accessibility lint | Automated |
| Every release | Keyboard pass on changed flows, focus and error handling checks | QA |
| Every week | Automated scan of a random sample of production pages | Automated |
| Every quarter | Mini-audit of critical flows with a screen reader | Lead or QA |
| Every year | Full WCAG-EM audit, ideally external; statement update | External audit |
| Before a major launch | Design review and audit of the new flow, like a DPIA | Design, QA, lead |
The weekly production scan is the one almost everyone skips, and it’s the one that finds the surprises: the promo banner added by marketing, the chat widget the vendor just updated, the page the CMS built from an old template.
CI with a baseline: no new barriers
The test I showed in the EAA guide fails if a page has even a single violation. That’s the right rule for a new site, but on an existing site with two hundred known violations the build would stay red for months, and a build that’s always red gets ignored.
The fix is the same one used for linters on legacy code: a baseline. Known violations are recorded in a versioned file, and CI fails only on new ones. The debt shrinks over time, but it never grows.
// tests/a11y-baseline.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
const BASELINE_FILE = "a11y-baseline.json";
const UPDATE = process.env.UPDATE_A11Y_BASELINE === "1";
const PAGES = ["/", "/products/", "/products/trail-shoes/", "/cart/", "/checkout/"];
// path -> list of already known "rule|selector" entries
type Baseline = Record<string, string[]>;
const baseline: Baseline = existsSync(BASELINE_FILE)
? JSON.parse(readFileSync(BASELINE_FILE, "utf-8"))
: {};
// All tests in the same worker, so afterAll writes a complete baseline
test.describe.configure({ mode: "serial" });
for (const path of PAGES) {
test(`${path}: no new WCAG violations`, async ({ page }) => {
await page.goto(path);
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
.analyze();
const current = violations.flatMap((v) => v.nodes.map((node) => `${v.id}|${node.target.join(" ")}`));
if (UPDATE) {
baseline[path] = current.sort();
return;
}
const known = new Set(baseline[path] ?? []);
const added = current.filter((f) => !known.has(f));
const fixed = [...known].filter((f) => !current.includes(f));
if (fixed.length > 0) {
console.log(`${path}: ${fixed.length} violations fixed, update the baseline`);
}
expect(added, "New violations compared to the baseline").toEqual([]);
});
}
test.afterAll(() => {
if (UPDATE) writeFileSync(BASELINE_FILE, JSON.stringify(baseline, null, 2) + "\n");
});
Two rules to keep the baseline from becoming a rug to sweep everything under:
- The baseline only goes down. Regenerating it to get a PR that adds violations through must require an explicit review by the accessibility lead, for example with
CODEOWNERSon the file. - The number of entries is a metric. If it hasn’t dropped after three months, the baseline is just hiding the problem.
Using the CSS selector as the key isn’t perfect: refactoring the markup can make old violations “appear” under a new selector. That’s an acceptable false positive, because it forces someone to look at the page, and it’s far better than a CI that checks nothing.
Production monitoring
CI checks the pages you know about. Monitoring checks the ones you don’t: everything that comes from the CMS, vendors and campaigns. A script that reads the sitemap, takes the critical flows plus a random sample and runs axe on each page:
// scripts/a11y-monitor.ts — Node 22.18+ runs TypeScript directly (needs "type": "module")
import { appendFile } from "node:fs/promises";
import { chromium } from "playwright";
import AxeBuilder from "@axe-core/playwright";
const SITE = process.env.SITE_URL ?? "https://example.com";
const SAMPLE_SIZE = Number(process.env.SAMPLE_SIZE ?? 25);
const CRITICAL = ["/", "/cart/", "/checkout/", "/login/"];
async function sitemapUrls(url: string): Promise<string[]> {
const xml = await (await fetch(url)).text();
const locs = [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]);
// A sitemap index lists other sitemaps, not pages
if (xml.includes("<sitemapindex")) return (await Promise.all(locs.map(sitemapUrls))).flat();
return locs;
}
function sample<T>(items: T[], n: number): T[] {
const copy = [...items];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy.slice(0, n);
}
const all = await sitemapUrls(`${SITE}/sitemap-index.xml`);
const targets = [...new Set([...CRITICAL.map((p) => new URL(p, SITE).href), ...sample(all, SAMPLE_SIZE)])];
const browser = await chromium.launch();
const page = await browser.newPage();
const rows: { url: string; rule: string; impact: string; nodes: number }[] = [];
for (const url of targets) {
await page.goto(url, { waitUntil: "networkidle" });
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
.analyze();
for (const v of violations) {
rows.push({ url, rule: v.id, impact: v.impact ?? "unknown", nodes: v.nodes.length });
}
}
await browser.close();
const report = [
`## Accessibility monitoring — ${new Date().toISOString().slice(0, 10)}`,
"",
`Pages checked: ${targets.length}. Violations: ${rows.length}.`,
"",
"| Page | Rule | Impact | Elements |",
"| --- | --- | --- | --- |",
...rows.map((r) => `| ${new URL(r.url).pathname} | ${r.rule} | ${r.impact} | ${r.nodes} |`),
].join("\n");
if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, report + "\n");
else console.log(report);
// Fail only on critical violations: the rest goes into the weekly report
process.exitCode = rows.some((r) => r.impact === "critical") ? 1 : 0;
And a GitHub Action that runs it every Monday morning:
# .github/workflows/a11y-monitor.yml
name: Accessibility monitoring
on:
schedule:
- cron: "0 6 * * 1" # every Monday at 06:00 UTC
workflow_dispatch:
jobs:
axe:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: node scripts/a11y-monitor.ts
env:
SITE_URL: https://example.com
The report shows up in the run summary. The natural next step is to open an issue automatically for every critical violation, but at the start a report the accessibility lead reads every Monday is more useful than fifty bot-generated issues.
Triage and accessibility debt
Rank barriers by impact on the user, not by number of occurrences or by WCAG criterion. A hundred decorative images missing alt="" matter less than a single “Pay” button you can’t reach with a keyboard. axe rates violations as critical, serious, moderate and minor; for triage I use a task-oriented scale:
| Level | Definition | Time to fix (example) |
|---|---|---|
| Blocker | A task can’t be completed (pay, log in, submit a form) | Next release, 5 working days at most |
| Serious | Possible only with great effort or outside help | 30 days |
| Moderate | Annoying, but there’s a workaround | 90 days |
| Minor | Slight annoyance, no impact on the task | Whenever the component is touched |
The timings are an example, not a legal requirement. What matters is that they’re written down, agreed on and measured, like response times for a security incident.
Every barrier becomes an issue with consistent labels, so the debt can be counted. A GitHub issue form also helps non-developers report problems properly:
# .github/ISSUE_TEMPLATE/accessibility.yml
name: Accessibility issue
description: A barrier that prevents or hinders using the site
labels: ["a11y"]
body:
- type: input
id: url
attributes:
label: Page or flow
placeholder: "/checkout/ — payment step"
validations:
required: true
- type: dropdown
id: impact
attributes:
label: Impact
options:
- "Blocker: I can't complete the task"
- "Serious: I can, but with great effort or help"
- "Moderate: annoying but there's a workaround"
- "Minor"
validations:
required: true
- type: input
id: wcag
attributes:
label: WCAG criterion (if known)
placeholder: "2.4.7 Focus Visible"
- type: textarea
id: environment
attributes:
label: Assistive technology and environment
placeholder: "NVDA + Firefox on Windows 11; keyboard only"
- type: textarea
id: steps
attributes:
label: Steps to reproduce and expected result
validations:
required: true
The accessibility statement
It’s the equivalent of the privacy policy: a public document telling users what to expect. The EAA requires service providers to explain how their service meets the accessibility requirements, in a format that is itself accessible. The W3C model for statements is a good starting point outside the public sector too. A useful statement includes:
- The reference standard: WCAG 2.2 level AA, EN 301 549.
- The conformance status: fully, partially or not conformant. “Partially conformant” with an honest list is worth more than “fully conformant” that collapses at the first test.
- Known non-conformities, with the reason and an alternative: “Historical price charts have no text alternative; the data is available as a table at this link”.
- How it was assessed: self-assessment or external audit, and the date.
- A reporting channel that actually works, with a stated response time.
- The enforcement procedure: where users can turn if the answer doesn’t come or isn’t satisfactory (the national authority for the EAA).
- The last review date, which must match the last audit, not the last deploy.
⚠ Attenzione
A statement copied from another site, or one that claims full conformance without an audit, is worse than no statement at all: it’s a false public claim, and the first user report will disprove it.
Vendors and third-party components
To users there is one site. They don’t care that the chat widget comes from a vendor, that the booking calendar is an iframe or that the cookie banner comes from a CMP. If they can’t close it with a keyboard, the site isn’t accessible. It’s the same reasoning as supply chain security: whatever you include becomes yours.
The components to check first, because they appear on every page or in the flows that bring in money:
- Cookie banners: this is where GDPR and accessibility meet. The banner must receive focus, work with a keyboard, have correct contrast and not cover the focused element (criterion 2.4.11).
- Payments in a provider’s iframe or redirect.
- Chat and assistants, including AI-based ones.
- Maps, calendars, video players, review widgets.
In contracts and tenders, ask for an up-to-date ACR, EN 301 549 conformance as a requirement, fix times for reported barriers and the right to test the component before buying. Exactly as you’d ask a data processor to guarantee appropriate security measures.
Inclusive design: before the code
Audits find problems. Inclusive design avoids creating them, and it’s much cheaper: moving a button in a mockup takes a minute, in production it takes a release.
Microsoft’s inclusive design principles are a good starting point, and they boil down to three ideas:
- Recognize exclusion. Every design decision includes someone and excludes someone else. Asking that question explicitly is half the work.
- Solve for one, extend to many. Captions designed for deaf people are used by everyone on a train. High contrast helps people with low vision and people reading in the sun.
- Learn from diversity. People with disabilities are experts at adapting to hard interfaces: involving them (and paying them) in testing produces insights no checklist gives you.
The most useful idea for teams is the persona spectrum: a limitation can be permanent, temporary or situational. Someone with one arm, someone with an arm in a cast and someone holding a newborn have the same need: using the interface with one hand. Designing for the first case solves the other two, and turns accessibility from a “feature for a few” into a quality of the product.
In practice, before a design reaches development:
- Palette from verified design tokens, dark theme included
- Focus, hover, error and disabled states designed, not left to chance
- Reading and tab order annotated in complex mockups
- Heading structure defined (one
h1, no skipped levels) - Alt text for informative images written by content people, not invented by the developer
- Interactive targets of at least 24×24 px, alternatives to dragging
- Error messages that explain how to fix the problem, not just that there is one
- Plain language: short sentences, common words, no internal jargon
Metrics: measure the program, not just the site
A program without metrics becomes something you do when there’s time. The ones I use:
- Coverage: percentage of critical flows audited in the last 12 months.
- Debt: open barriers by impact level, and entries in the CI baseline. Both must go down.
- Time to fix: from report to fix, by impact level, against the agreed times.
- Regressions blocked: how many PRs CI stopped. A high number early on is a good sign: it means the check works.
- User reports: how many come in and how quickly they get an answer.
Mistakes to avoid
- One-off audits. A two-year-old audit PDF describes a site that no longer exists.
- Automated tests only. They find part of the problems; the rest needs keyboards, screen readers and people.
- “Magic” overlays. They don’t fix the code and often make the screen reader experience worse. I covered them in the EAA guide.
- Accessibility as a developer task. If design and content aren’t involved, every sprint creates new barriers to fix.
- A decorative statement. Published once, never updated, with an email address nobody reads.
- Ignoring vendors. The most-used third-party component on the site is often the least accessible one.
Program checklist
- An accessibility lead with authority over the backlog
- Inventory of templates, critical flows and third-party components
- Yearly WCAG-EM audit, with a structured and a random sample
- axe in CI with a baseline that can only go down
- Weekly scan of a sample of production pages
- Manual keyboard and screen reader tests on changed flows for every release
- Impact scale and fix times written down and measured
- Reporting form for the team and a public channel for users
- Honest, dated accessibility statement, updated after every audit
- EN 301 549 requirements and ACRs in vendor contracts
- Accessibility review in design, before development
- Training for design, development, QA and content
Frequently asked questions
❓ How often should you run an accessibility audit?
A full WCAG-EM audit at least once a year and before every major launch or redesign. In between you need lighter checks: axe in CI on every pull request, a weekly scan in production and a quarterly mini-audit of critical flows with a screen reader.
❓ Does the audit have to be done by an external consultant?
It isn’t mandatory, but an external audit at least once a year is advisable: the people who built the site tend to use it as intended rather than the way a real user would. Frequent checks can stay in-house, as long as the team is trained to use keyboards and screen readers.
❓ What is an accessibility baseline in CI?
A versioned file listing the violations that are already known. The pipeline fails only when a new violation appears, so a site with a lot of existing debt can block regressions right away, while the number of known violations is reduced bit by bit.
❓ What should an accessibility statement contain?
The reference standard, the conformance status, known non-conformities with the available alternatives, the assessment method and date, a reporting channel with response times and the complaint procedure with the competent authority.
❓ Am I responsible for third-party widgets too?
From the user’s point of view, yes: if the cookie banner, the chat or the payment step can’t be used, the service isn’t accessible. That’s why it pays to put EN 301 549 conformance in contracts, ask for an up-to-date ACR and test the component before adopting it.
Conclusion
GDPR started working when companies stopped seeing it as a document to sign and started treating it as a process with roles, records, checks and vendors. Accessibility is following the same path, with one advantage: much of the work can be automated. A CI baseline, a weekly scan, a yearly audit, an honest statement and a design process that thinks about inclusion before the code are enough to turn compliance from a project with an expiry date into a property of the product.
References:

Co-Founder & CTO at PAPION. Senior full-stack engineer specializing in React, TypeScript, Node.js, and application security.