Your React app passes axe-core with zero violations. Lighthouse gives you 100 for accessibility. Then the compliance audit comes back with a list of failures.
That happened to me on a commercial project where I was responsible for accessibility. The site had to meet national requirements, and the auditors weren't checking against WCAG directly. Most of the gap came from a mismatch between the tools developers use and the documents auditors read. Fixing that mismatch turned into an open-source toolkit, ru-a11y-toolkit, and later into my graduation thesis.
This post covers why the gap exists, how the toolkit is put together, and a few engineering decisions that might be useful even if you never touch Russian regulations.
The gap between WCAG and the law
Most countries don't make WCAG itself the law. They adopt a national standard based on it. The EU has EN 301 549, which matters a lot more now that the European Accessibility Act applies. The US has Section 508. Russia has GOST R 52872-2019, which is based on WCAG 2.1, and since March 2026 also Government Decree No. 102.
These documents start from WCAG, but they don't simply copy it:
- They phrase requirements differently. One WCAG criterion can turn into several separate clauses, and an auditor quotes the clause, not the success criterion.
- They add requirements of their own. Decree 102 says pages must stay usable at 200% zoom without horizontal scrolling, page titles must describe what the page is for, and confirmations must be in the state language. Public sector sites are also expected to offer a "version for the visually impaired": a readability mode visitors can turn on.
- The audit report is in the local language and cites local clauses. A developer sees
img elements must have an alt attribute, and the auditor writes "violation of §5.1.1". Somebody has to translate between the two every time.
eslint-plugin-jsx-a11y and axe-core are excellent, but they speak English and cite WCAG. On a project with regional requirements, mapping their output to the audit report becomes manual work that someone has to repeat every sprint.
So I wanted tools that report every problem the way an auditor would: in Russian, with the GOST clause, the Decree 102 item and the WCAG criterion, and a concrete fix.
Architecture: one catalog, three checkpoints
The toolkit is a monorepo with five packages plus an umbrella package:
| Package | Role |
|---|---|
core | Rule catalog: 115 rules with texts, normative references and id aliases |
eslint | ESLint plugin: 10 custom JSX rules, 26 translated jsx-a11y rules, 3 configs |
overlay | React dev overlay: axe-core plus 44 custom DOM checks |
cli | CI auditor: Puppeteer + axe-core + ESLint, console/JSON/HTML/JUnit reports |
visually-impaired | Readability mode for site visitors |
Two ideas shaped the design.
One source of truth
Every rule title, description, fix and normative reference lives in core and nowhere else. The ESLint messages, the overlay cards and the CLI reports are all built from that catalog:
import { getRuleMeta } from 'ru-a11y-toolkit-core'; getRuleMeta('image-alt'); // { // id: 'image-alt', // title: 'Изображение без альтернативного текста', // "Image without alternative text" // fix: 'Добавьте <img alt="..." /> или alt="" для декоративных.', // gost: 'ГОСТ Р 52872-2019, §5.1.1', // post102: 'Постановление №102, п. г)', // wcag: 'WCAG 2.1, 1.1.1 Non-text Content', // principle: 'perceivable', // severity: 'error', // } ESLint rule ids and axe-core ids are aliases of catalog entries, so ru-a11y/require-skip-link and axe's bypass resolve to the same rule. The same problem reads the same way in the editor, in the browser and in the CI report. When a developer pastes a CI failure into a ticket, the auditor recognizes it.
Shift left, then verify
Each tool catches a different class of problems at a different stage:
- ESLint catches what's visible in JSX while you type: a missing skip link, a table without
<th>, a viewport that blocks zoom. - The overlay checks the real DOM in the browser during development: contrast, focus order, duplicate ids, anything that only exists after render.
- The CLI audits deployed pages in CI and fails the build when new issues show up.
The packages depend only on core and their own engine (ESLint, axe-core or Puppeteer). You can adopt one without the others.
Translating jsx-a11y without forking it
I didn't want to rewrite detection logic that eslint-plugin-jsx-a11y already gets right. I only needed different messages. That turned out to be harder than expected.
Normally you'd override meta.messages. But jsx-a11y reports plain message strings, not messageIds, so there's nothing to override. And in ESLint 9 context.report is frozen, so you can't monkey-patch it either.
The fix is to give the original rule a different context: one that inherits everything from the real context through the prototype chain and overrides only report:
function createTranslatingContext(context, mappings) { const translatingContext = Object.create(context); Object.defineProperty(translatingContext, 'report', { configurable: true, writable: true, value(descriptor) { if (descriptor && typeof descriptor.message === 'string') { return context.report({ ...descriptor, message: translate(descriptor.message, mappings) }); } return context.report(descriptor); }, }); return translatingContext; } function wrapJsxRule(originalRule, mappings = []) { return { meta: { ...originalRule.meta }, create(context) { return originalRule.create(createTranslatingContext(context, mappings)); }, }; } The original rule runs unchanged and never knows it's wrapped. It gets bug fixes from upstream for free, and 26 jsx-a11y rules now speak the auditor's language. The same pattern works for any plugin whose messages you need to localize or enrich, for example to add links to your internal docs.
Rules that didn't exist yet
Some requirements have no counterpart in jsx-a11y, so I wrote 10 custom rules. zoom-200-warning is a good example, because it comes straight from Decree 102. It flags:
- a viewport meta with
user-scalable=noormaximum-scalebelow 2; - inline font sizes below a configurable minimum (with per-unit thresholds, since
emis relative to the parent); - fixed inline widths over 200px, which tend to cause horizontal scrolling at 200% zoom.
// Both are reported <meta name="viewport" content="width=device-width, maximum-scale=1" /> <p style={{ fontSize: 10 }}>Note</p> Page-level rules such as require-h1 and require-main-landmark need a different trick. A linter sees files, not pages, and a <Button> component shouldn't be asked for an <h1>. These rules only run on page-like files: anything under app/, pages/, routes/ or layouts/, or files named page, layout, App and so on. It's a heuristic, but it matches how Next.js, Remix and most React routers organize code, and it removes almost all of the noise.
A dev overlay that keeps up with an SPA
The overlay renders a panel in development builds, lists violations grouped by WCAG principle and highlights the element when you click one.
A few details took more time than I expected:
- Re-scanning. A
MutationObserverwith a debounce re-runs the scan after DOM changes, so client-side navigation doesn't leave stale results. A scan that starts while another one is still running must not briefly show "no violations". That bug really shipped once. - Deduplication. When axe-core and a custom check report the same rule on the same element, the user should see one card, not two.
- Unique selectors. An early version merged findings on different elements that shared a tag, and clicking a card could highlight the wrong element. Every finding now gets a unique CSS selector.
- Excluding itself. The overlay has buttons and text too, and it must never audit its own UI.
In total the overlay runs 148 checks: 104 from axe-core and 44 custom ones for things GOST and Decree 102 care about that axe doesn't cover.
Making it fail the build
Dev tools only help when someone looks at them. The CLI is what makes accessibility a real gate:
npx ru-a11y-cli --urls-file urls.txt --format junit --output reports --max-issues 0 It opens each URL in headless Chromium, runs axe-core, can also lint the source with --with-eslint, and merges everything into one report. Exit codes are deliberate: 0 means within the limit, 1 means too many issues, and 2 means the audit itself failed (a timeout or a DNS error). That way CI doesn't mistake "the site is down" for "the site is accessible".
JUnit output was the most useful format in practice. GitHub, GitLab and Jenkins all render it, so accessibility failures show up next to unit test failures instead of in a separate HTML file nobody opens.
Keeping the docs honest
The README says 115 rules, 36 ESLint checks and 148 overlay checks. Numbers like that go stale the moment someone adds a rule and forgets the docs.
So the counts live in one object in core:
export const RU_A11Y_TOOLKIT_STATS = { canonicalRules: 115, eslintChecks: { total: 36, base: 26, custom: 10 }, overlayChecks: { total: 148, axe: 104, custom: 44 }, } as const; Every package has a test that compares its real rule count with this object. If the numbers drift, CI fails. It's a small thing, but it means the published numbers can be trusted.
The visually impaired mode
The last package is for site visitors, not developers. It's a ready-made settings panel plus hooks for building your own: font size, line height and letter spacing, color schemes (white on black, blue on yellow, and so on), link highlighting, grayscale or hidden images, disabled animations and a thick focus outline.
It works in two layers. Each setting is applied separately as a data- attribute on <html>, and a "full mode" class adds a preset on top. Full mode never downgrades a stronger choice: if the visitor picked 150% text, turning on full mode keeps 150%. Settings persist in localStorage, are validated on read (a stale or corrupted value falls back to the default) and stay in sync across open tabs.
What automation can't do
Automated checks find a large share of accessibility problems, but not all of them. Keyboard flows, screen reader announcements and whether an alt text actually describes the image still need a human. The toolkit doesn't replace manual testing. It makes sure that by the time a person tests, the obvious issues are already gone, and every remaining one is reported in terms the auditor will recognize.
Takeaways
Even if you never build for the Russian market, the pattern carries over to EN 301 549, Section 508 or any internal accessibility policy:
- Keep rule metadata in one catalog. Lint messages, runtime warnings and CI reports should all come from the same source.
- Wrap good tools instead of forking them. A proxy context is enough to localize or enrich another plugin's output.
- Check at three stages: static in the editor, runtime in development, end-to-end in CI.
- Make CI distinguish "failed the audit" from "couldn't run the audit".
- Test your documentation numbers.
The code is MIT-licensed and on GitHub: biondohod/ru-a11y. To try it:
npm install --save-dev ru-a11y-toolkit If you've built compliance tooling for other regional standards, I'd like to hear how you approached it. Issues and PRs are welcome.


