Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

a11y: accessibility

a11y loads every page in a headless browser and runs pa11y, which uses HTML_CodeSniffer to test the rendered page against a WCAG conformance level. It catches the problems that are mechanical to detect: missing alternative text, form fields without labels, insufficient colour contrast, empty links and buttons, broken ARIA references. It does not replace testing with a keyboard and a screen reader, but it stops regressions in the parts that can be tested automatically.

Needs

npm i -D pa11y puppeteer

The audit loads pages over HTTP (requires: 'server'): vidimus serves the build locally, or uses --origin.

Run it

npx vidimus a11y

It is part of the default set.

What it checks

Every built page of the default locale that is not excluded is loaded in its own tab and passed to pa11y with the configured a11y.standard. Pages under translated locales are skipped unless you pass --all-locales (or set allLocales: true). With a11y.sample, pages whose URL path matches a pattern are reduced to the first matching page per pattern; pages that match no pattern are all tested. Use it for templates that repeat, such as blog posts.

pa11y reports errors only (not its warnings and notices), each with a WCAG code, a CSS selector for the element and an HTML snippet. vidimus groups them:

  • One finding per distinct issue code and selector. The same problem in a shared header or footer is reported once, with every page it appears on.
  • The finding message is pa11y’s own message. The details are the selector, the WCAG code and, when pa11y provides one, the element’s HTML.
  • The fix links to the WCAG technique named in the code when there is one (G18 becomes https://www.w3.org/WAI/WCAG21/Techniques/general/G18).

A page that fails to load, or does not finish within a11y.timeout, becomes its own finding (failed to audit /path/: <error>) instead of stopping the audit.

pa11y does not enter cross-origin iframes, so problems inside an embed are not reported. The <iframe> element itself is: one without a title fails WCAG2AA.Principle2.Guideline2_4.2_4_1.H64.1. Give every iframe a title naming its content (title="Video: product tour").

Elements matched by a11y.hideElements are removed from the test, and issue codes listed in a11y.ignore are dropped by pa11y before vidimus sees them.

Example output

─── a11y ────────────────────────────────────────────────────────

24 pages against WCAG2AA

✖ This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 3.54:1. Recommendation:  change text colour to #767676.
    footer > p.copyright
    WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail
    <p class="copyright">© 2026 Example</p>
    on: / /about/ /blog/ +21 more
    → Apply https://www.w3.org/WAI/WCAG21/Techniques/general/G18 to the element listed, or add the code to a11y.ignore if it is a false positive.

✖ Img element missing an alt attribute. Use the alt attribute to specify a short text alternative.
    #main > article > img
    WCAG2AA.Principle1.Guideline1_1.1_1_1.H37
    <img src="/img/team.avif" width="1200" height="800">
    on: /about/
    → Apply https://www.w3.org/WAI/WCAG21/Techniques/html/H37 to the element listed, or add the code to a11y.ignore if it is a false positive.

✖ failed to audit /map/: Pa11y timed out (60000ms)
    → Check the page loads in a browser, raise a11y.timeout (now 60000ms) or add the page to a11y.exclude.

✖ a11y: 2 distinct issue(s), 1 page(s) failed to load (41.7s)

A passing run:

✔ a11y: 24 pages, no WCAG2AA violations (38.2s)

Options

KeyDefaultDescription
a11y.standard'WCAG2AA'WCAG2A, WCAG2AA or WCAG2AAA
a11y.ignore[]pa11y issue codes to ignore, e.g. WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail
a11y.hideElements''CSS selector of elements pa11y skips, e.g. '.third-party-widget, iframe'
a11y.timeout60000ms pa11y may spend on one page
a11y.concurrencyhalf the cores (2 to 8)pages tested at the same time
a11y.exclude[]URL path patterns to skip, e.g. ^/embed/
a11y.sample[]URL path patterns: test one page per matching template

Config example

import { defineConfig } from 'vidimus';

export default defineConfig({
  a11y: {
    standard: 'WCAG2AA',
    hideElements: '.cookie-banner',
    exclude: ['^/embed/'],
    sample: ['^/blog/[^/]+/$'],
  },
});

a11y.ignore drops a code everywhere. To drop an issue on some pages only, use a top-level ignore rule with where (see Configuration):

import { defineConfig } from 'vidimus';

export default defineConfig({
  ignore: [{ audit: 'a11y', message: '^This element has insufficient contrast', where: '^/legacy/' }],
});

Common fixes

Images need alternative text, or an empty alt when they are decorative:

<img src="/img/team.avif" alt="The team at the 2026 offsite" width="1200" height="800">
<img src="/img/divider.svg" alt="" width="600" height="20">

Form fields need a label the browser can associate with them:

<label for="email">Email</label>
<input id="email" type="email" name="email" autocomplete="email">

Links and buttons that contain only an icon need an accessible name:

<a href="https://github.com/example/site" aria-label="Source on GitHub">
  <svg aria-hidden="true" focusable="false">…</svg>
</a>

Tips

  • Contrast checks on text over background images or gradients can be wrong in either direction. Check the element in the browser, and ignore the finding for that page if it is a false positive.
  • On a small CI runner, lower a11y.concurrency if pages time out: the other browser audits load pages at the same time.
  • Content injected by third-party scripts (chat widgets, embeds) is tested too. Exclude it with a11y.hideElements if you cannot change it.

These docs are deliberately built 8 times, with VitePress, Starlight, Hugo, Eleventy, Zola, mdBook, React and Angular, from the same markdown, and audited by vidimus as one site. Why.