Accessibility stopped being a nice-to-have for static sites the moment the European Accessibility Act's obligations began applying on 28 June 2025. If you sell to consumers in the EU — e-commerce, banking, ticketing, e-books, transport — your website and app are in scope, and the harmonised standard behind it, EN 301 549, points straight at WCAG. In the US, the DOJ's ADA Title II rule puts state and local government sites (and their vendors) on a WCAG 2.1 AA clock with deadlines in April 2026 and April 2027. Meanwhile WCAG 2.2 added criteria — focus visibility, target size, accessible authentication — that most sites built before 2023 have never been checked against.
The good news for a Gatsby site is structural: every route exists as a file in public/ after a build, so you can audit all of them automatically, before deploy, in CI. Server-rendered SPAs can only test the pages someone remembers to list. This tutorial covers a practical Gatsby 5 accessibility pipeline: what to check, how to wire the automated part into a build, the Gatsby-specific failures that come up on nearly every project, and the manual checks that no tool will do for you.
What "compliant" actually means
Pick a target and write it down before you start auditing, because "make the site accessible" is not a scope.
- WCAG 2.2 Level AA is the sensible default. It is a superset of 2.1 AA, which is what EN 301 549 and the ADA Title II rule reference, so building to 2.2 AA satisfies both and future-proofs you.
- Level AAA is not the goal. Some AAA criteria are impossible for whole categories of content, and the standard itself says conformance to AAA across a whole site is not a reasonable general policy.
- Automated tools catch roughly a third of issues. Every vendor that publishes honest numbers lands somewhere between 20% and 40% of WCAG failures detectable by static analysis. Automation is your regression net, not your audit.
Write the target and the date into an accessibility statement page — the EAA expects one, and it is also the cheapest way to give users a route to report problems.
Step 1: catch what you can at author time
Gatsby's default development experience already includes eslint-plugin-jsx-a11y rules as warnings. Warnings get ignored. Promote the meaningful ones to errors so a broken component fails the build, not a code review:
// .eslintrc.js
module.exports = {
plugins: ['jsx-a11y'],
extends: ['plugin:jsx-a11y/recommended'],
rules: {
'jsx-a11y/alt-text': 'error',
'jsx-a11y/anchor-has-content': 'error',
'jsx-a11y/anchor-is-valid': 'error',
'jsx-a11y/label-has-associated-control': 'error',
'jsx-a11y/no-redundant-roles': 'error',
'jsx-a11y/no-noninteractive-element-interactions': 'error',
'jsx-a11y/click-events-have-key-events': 'error',
'jsx-a11y/heading-has-content': 'error',
},
};
This is cheap and it stops the two most common regressions — an image added without alt, and a <div onClick> that no keyboard can reach.
Step 2: audit every built page with axe, from the sitemap
The core of the pipeline. Build the site, serve public/ locally, read the sitemap you already generate with gatsby-plugin-sitemap, and run axe-core against every URL in a real browser. Playwright plus @axe-core/playwright is the least fragile combination:
npm i -D @playwright/test @axe-core/playwright serve fast-xml-parser
npx playwright install --with-deps chromium
// a11y/audit.spec.js
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
const fs = require('node:fs');
const { XMLParser } = require('fast-xml-parser');
const BASE = process.env.A11Y_BASE_URL || 'http://localhost:9000';
function urlsFromSitemap() {
const xml = fs.readFileSync('public/sitemap-0.xml', 'utf8');
const parsed = new XMLParser().parse(xml);
const entries = [].concat(parsed.urlset.url);
return entries
.map((u) => new URL(u.loc).pathname)
.filter((p) => !p.startsWith('/dev-404'));
}
for (const path of urlsFromSitemap()) {
test(`a11y: ${path}`, async ({ page }) => {
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
const violations = results.violations.map((v) => ({
id: v.id,
impact: v.impact,
nodes: v.nodes.map((n) => n.target.join(' ')),
}));
expect(violations, JSON.stringify(violations, null, 2)).toEqual([]);
});
}
Run it against the production build, not gatsby develop — the dev server injects overlays and skips the static HTML path you actually ship:
npx gatsby build
npx serve -l 9000 public &
npx playwright test a11y/audit.spec.js
On a site with hundreds of generated pages, audit a sample rather than the whole set on every commit: all hand-built pages, plus three or four representative pages per template. A full crawl once a week on a schedule catches content-authored regressions without adding ten minutes to every pull request.
If you prefer a zero-config option, pa11y-ci will read a sitemap directly:
npx pa11y-ci --sitemap https://staging.example.com/sitemap-0.xml \
--sitemap-exclude "\\.pdf$" --standard WCAG2AA
It is easier to set up and harder to extend. For a one-off audit it is fine; for a long-lived project the Playwright version pays for itself the first time you need to log in, dismiss a cookie banner, or test a page in an open menu state.
Step 3: wire it into CI as a blocking check
# .github/workflows/a11y.yml
name: accessibility
on:
pull_request:
schedule:
- cron: '0 6 * * 1'
jobs:
axe:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx gatsby build
- run: npx playwright install --with-deps chromium
- run: npx serve -l 9000 public &
- run: npx wait-on http://localhost:9000
- run: npx playwright test a11y/audit.spec.js
- uses: actions/upload-artifact@v4
if: always()
with:
name: a11y-report
path: playwright-report/
Introduce it as non-blocking for a sprint, fix the backlog it surfaces, then make it required. A check that is red on day one and stays red teaches everyone to ignore it.
Gatsby-specific failures we find on almost every project
Generic advice will not find these. They are artefacts of how Gatsby builds and navigates.
Duplicate or missing <title>, which breaks route announcements
Gatsby ships a route announcer: after a client-side navigation it puts the new page's document.title into a visually hidden live region so screen reader users know the page changed. It is only as good as your titles. If half your templates render the same title, or a template forgets one entirely, a screen reader user hears nothing useful when they navigate — a 2.4.2 (Page Titled) failure that no axe run on a single page will flag.
Check the whole build in one line:
grep -ho '<title[^>]*>[^<]*</title>' -r public --include=index.html \
| sort | uniq -c | sort -rn | head -20
Any count above 1 is worth a look. Fix it in the Gatsby 5 Head API, not react-helmet:
export const Head = ({ data }) => (
<>
<title>{`${data.markdownRemark.frontmatter.title} | Example Co`}</title>
<html lang="en" />
</>
);
The <html lang> matters too — 3.1.1 (Language of Page) is one of the most common failures on Gatsby sites, because the attribute lives in html.js, a file most teams never eject or touch.
Focus is not moved after client-side navigation
Gatsby announces the route change but does not move keyboard focus. A keyboard user who clicks a nav link stays focused where they were, and the next Tab continues from the old position — disorienting, and a 2.4.3 (Focus Order) problem in practice. Reset focus to the main landmark on navigation:
// src/components/layout.js
import React, { useEffect, useRef } from 'react';
import { useLocation } from '@reach/router';
export default function Layout({ children }) {
const mainRef = useRef(null);
const { pathname } = useLocation();
const first = useRef(true);
useEffect(() => {
if (first.current) { first.current = false; return; } // don't steal focus on load
mainRef.current?.focus();
}, [pathname]);
return (
<>
<a className="skip-link" href="#main">Skip to main content</a>
<Header />
<main id="main" ref={mainRef} tabIndex={-1} style={{ outline: 'none' }}>
{children}
</main>
<Footer />
</>
);
}
tabIndex={-1} makes the element programmatically focusable without adding it to the tab order. Guarding the first render matters: moving focus on initial page load is its own bug.
Images from gatsby-plugin-image
alt is a required prop on GatsbyImage, but it is not required to be meaningful, and CMS-sourced images almost always arrive with alt empty or set to the filename. Two rules:
// Decorative — empty alt, hidden from assistive tech
<GatsbyImage image={img} alt="" />
// Informative — alt comes from the CMS field, with a real fallback path
<GatsbyImage image={img} alt={node.altText || ''} />
Never fall back to the filename or the post title. If your CMS has no alt field, adding one is the fix; a decorative empty alt is honest, alt="hero-image-final-2.jpg" is not. Audit the built HTML for the smell:
grep -rho 'alt="[^"]*\.\(jpg\|png\|webp\)"' public --include=index.html | sort -u
MDX and Markdown heading order
Content authors write ## then #### because it looks right. That is a 1.3.1 structure failure, and on a Gatsby site the template usually contributes the <h1>, so the body headings start at the wrong level anyway. Enforce it in the remark pipeline with remark-lint, or add a build-time check that parses each rendered page and asserts exactly one <h1> and no skipped levels. The check is twenty lines and it holds the line permanently.
The 404 page
src/pages/404.js is not in the sitemap, so it never gets audited, and it is frequently the least accessible page on the site. Add it to the URL list explicitly.
What WCAG 2.2 added that older sites fail
Four criteria worth a targeted pass, because pre-2023 designs routinely miss them:
- 2.4.11 Focus Not Obscured (Minimum) — a sticky header or cookie banner must not completely hide the focused element. Tab through the page with a sticky nav visible; this is one of the most common new failures.
- 2.5.8 Target Size (Minimum) — interactive targets need to be at least 24×24 CSS pixels, or be adequately spaced. Icon-only social links and pagination arrows are the usual offenders.
- 3.2.6 Consistent Help — if a help mechanism (contact link, chat) appears on multiple pages, it must be in the same relative order on each. Easy on a Gatsby site with a shared layout; easy to break with a one-off landing page template.
- 3.3.8 Accessible Authentication (Minimum) — no cognitive-function test (like transcribing a code from an image) without an alternative. Applies to any gated area, including a client portal bolted onto a static marketing site.
The manual pass automation will never replace
Budget half a day per template family:
- Keyboard only. Unplug the mouse. Reach every interactive element, in a sensible order, with a visible focus indicator throughout. Escape closes modals and menus; focus returns where it came from.
- One screen reader. VoiceOver on macOS/Safari or NVDA on Windows/Firefox. Read the page top to bottom, then navigate by heading and by landmark. If the landmark list is meaningless, your markup is.
- 400% zoom / 320px viewport. WCAG 1.4.10 reflow. Static sites with wide data tables and code blocks fail this constantly — code blocks need
overflow-x: auto, not a broken layout. - Contrast on real backgrounds. Automated contrast checks fail silently on text over images, gradients, and semi-transparent overlays. Check those by hand.
prefers-reduced-motion. Turn it on at the OS level and reload. Parallax and scroll-triggered animation should stop, not just slow down.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Sequencing the remediation
On an existing site, fix in this order — it front-loads the changes that affect the most pages for the least work:
- Global template issues:
<html lang>, skip link, landmarks, focus reset, focus visibility. One commit, every page fixed. - Colour and contrast tokens in the design system. Second-largest blast radius.
- Forms: labels, error association with
aria-describedby, anaria-liveregion for submit status. Your contact form is the page that converts; it is also the page most likely to be unusable with a screen reader. - Component-level issues found by axe, worst impact first.
- Content-authored issues (heading order, link text, alt text) plus an editorial guideline so they stop coming back.
Then publish the accessibility statement, keep the CI check green, and re-run the manual pass whenever a template changes.
Why static sites have the advantage here
Every route is a file, the HTML is complete before a browser runs a line of JavaScript, and the sitemap is a free, authoritative list of everything you ship. That combination makes full-site accessibility auditing a solved engineering problem on Gatsby in a way it simply is not on a client-rendered app. The remaining work is design and content judgement — which is where it should be.
If you would like an accessibility audit of an existing Gatsby or static site, or help wiring the CI pipeline above into a project you already run, get in touch.