Setting Up Lighthouse CI Thresholds for WCAG 2.2 AA
“The pipeline enforces WCAG 2.2 AA” is a sentence about a specific list of assert.assertions keys, or it is a sentence about nothing. This guide is part of Lighthouse CI Baseline Configuration, and it does one thing: convert a Level AA conformance target into individual Lighthouse audit IDs, each with a defensible error or warn level, and state plainly which parts of AA no Lighthouse assertion can reach.
Root Cause
The instinct is to write one assertion — 'categories:accessibility': ['error', { minScore: 1 }] — and call the job a conformance gate. That single key fails in two opposite directions at once, which is why it survives review: it looks strict, so nobody checks whether it is correct.
It is too blunt because the accessibility category is not a WCAG catalogue. It contains audits that correspond to no success criterion at all: heading-order flags a jump from h2 to h4, which is a documented best practice but not a WCAG failure at any level; skip-link and image-redundant-alt are likewise advisory. At minScore: 1 those advisory audits block merges with the same authority as a missing form label, and the first time a legitimate h2-to-h4 jump blocks a release, someone downgrades the whole category assertion and the real failures go with it. The category score also has a moving denominator: audits that find nothing to check are marked not applicable and dropped from the weighted average entirely, so a page with no images cannot fail image-alt and its 1.0 means less than another page’s 0.94.
It is simultaneously not equivalent to AA conformance because most of Level AA is not machine-checkable. WCAG 2.2 has 56 success criteria at Level A and AA combined, and six of those are new in 2.2 — SC 2.4.11 Focus Not Obscured (Minimum), SC 2.5.7 Dragging Movements, SC 2.5.8 Target Size (Minimum), SC 3.2.6 Consistent Help, SC 3.3.7 Redundant Entry and SC 3.3.8 Accessible Authentication (Minimum). Lighthouse has an audit for exactly one of the six: target-size for SC 2.5.8. A perfect 1.0 score is therefore consistent with a page that obscures focus behind a sticky header, requires a drag gesture with no single-pointer alternative, and re-asks for information the user already entered. Eleven audits in the category exist purely as manual placeholders — focus-traps, managed-focus, logical-tab-order, visual-order-follows-dom and friends — which report scoreDisplayMode: 'manual' and never produce a number to assert against.
The fix is mechanical. Enumerate the criteria you claim to enforce, find the audit IDs that evidence them, assign each one a level based on how deterministic its result is, and route everything left over to a human checklist rather than pretending an assertion covers it.
Configuration
The mapping table below is the working document. Each row names one Lighthouse audit ID, the success criterion it provides evidence for, that criterion’s level, how deterministic the audit’s verdict is in a CI browser, and the resulting assertion level. Fifteen distinct success criteria appear across these rows — out of the 56 at Level A and AA.
| Audit ID | Success criterion | Level | Determinism | Assertion |
|---|---|---|---|---|
image-alt |
SC 1.1.1 Non-text Content | A | High | error, minScore 1 |
input-image-alt |
SC 1.1.1 Non-text Content | A | High | error, minScore 1 |
object-alt |
SC 1.1.1 Non-text Content | A | High | error, minScore 1 |
video-caption |
SC 1.2.2 Captions (Prerecorded) | A | High | error, minScore 1 |
label |
SC 3.3.2 Labels or Instructions | A | High | error, minScore 1 |
form-field-multiple-labels |
SC 3.3.2 Labels or Instructions | A | Medium | warn |
button-name |
SC 4.1.2 Name, Role, Value | A | High | error, minScore 1 |
select-name |
SC 4.1.2 Name, Role, Value | A | High | error, minScore 1 |
aria-required-attr |
SC 4.1.2 Name, Role, Value | A | High | error, minScore 1 |
aria-valid-attr-value |
SC 4.1.2 Name, Role, Value | A | High | error, minScore 1 |
aria-hidden-focus |
SC 4.1.2 Name, Role, Value | A | High | error, minScore 1 |
aria-required-children |
SC 1.3.1 Info and Relationships | A | Medium | warn |
td-has-header |
SC 1.3.1 Info and Relationships | A | Medium | warn |
link-name |
SC 2.4.4 Link Purpose (In Context) | A | High | error, minScore 1 |
document-title |
SC 2.4.2 Page Titled | A | High | error, minScore 1 |
html-has-lang |
SC 3.1.1 Language of Page | A | High | error, minScore 1 |
valid-lang |
SC 3.1.2 Language of Parts | AA | High | error, minScore 1 |
color-contrast |
SC 1.4.3 Contrast (Minimum) | AA | High on static text | error, minScore 1 |
meta-viewport |
SC 1.4.4 Resize Text | AA | High | error, minScore 1 |
link-in-text-block |
SC 1.4.1 Use of Color | A | Medium | warn |
label-content-name-mismatch |
SC 2.5.3 Label in Name | A | Medium | warn |
target-size |
SC 2.5.8 Target Size (Minimum) | AA | Low under emulation | warn |
bypass |
SC 2.4.1 Bypass Blocks | A | Medium | warn |
heading-order |
none — best practice only | — | High | warn |
skip-link |
none — best practice only | — | Medium | omit |
image-redundant-alt |
none — best practice only | — | Low | omit |
Four rows carry the argument. color-contrast is the AA workhorse and it is genuinely deterministic on static text, so it belongs on error; its false positives come from text over gradients and images, which is a fixable-in-markup problem rather than a scanner problem. label evidences SC 3.3.2 and is binary — a control either resolves to a label or it does not. The aria-* audits are all facets of SC 4.1.2, and the three listed above are the ones that fail only on genuinely broken markup, unlike aria-required-children, which fires on valid composite widgets that render children asynchronously. heading-order maps to no success criterion at any level; keep it as a warn because heading structure is worth watching, and never let it block, because blocking on it is how the whole gate loses its mandate. target-size maps to a real AA criterion new in 2.2, but it measures rendered geometry under Lighthouse’s emulated mobile viewport, so a web font that reflows a button for three frames produces a failure that does not exist in any real browser.
// lighthouserc.js — an assert block that states a WCAG 2.2 AA position.
// No preset: every audit listed here is asserted, nothing else is.
const hard = { aggregationMethod: 'median', minScore: 1 };
const soft = { aggregationMethod: 'median', minScore: 1 };
module.exports = {
ci: {
assert: {
includePassedAssertions: true, // proves in the log that an audit ran
assertions: {
// SC 1.1.1 Non-text Content
'image-alt': ['error', hard],
'input-image-alt': ['error', hard],
'object-alt': ['error', hard],
// SC 1.2.2 Captions (Prerecorded)
'video-caption': ['error', hard],
// SC 1.4.3 Contrast (Minimum) — the AA criterion this gate exists for
'color-contrast': ['error', hard],
// SC 1.4.4 Resize Text: user-scalable=no or maximum-scale<5 fails
'meta-viewport': ['error', hard],
// SC 3.3.2 Labels or Instructions
'label': ['error', hard],
// SC 4.1.2 Name, Role, Value
'button-name': ['error', hard],
'select-name': ['error', hard],
'link-name': ['error', hard],
'aria-required-attr': ['error', hard],
'aria-valid-attr-value': ['error', hard],
'aria-hidden-focus': ['error', hard],
// SC 2.4.2 Page Titled, SC 3.1.1/3.1.2 Language
'document-title': ['error', hard],
'html-has-lang': ['error', hard],
'valid-lang': ['error', hard],
// Real criteria, unreliable verdicts: report, never block.
'target-size': ['warn', soft], // SC 2.5.8
'bypass': ['warn', soft], // SC 2.4.1
'link-in-text-block': ['warn', soft], // SC 1.4.1
'label-content-name-mismatch': ['warn', soft], // SC 2.5.3
'aria-required-children': ['warn', soft], // SC 1.3.1
'td-has-header': ['warn', soft], // SC 1.3.1
'form-field-multiple-labels': ['warn', soft], // SC 3.3.2
// No success criterion behind it; watched, never blocking.
'heading-order': ['warn', soft],
// Trend line only. Never the gate: the denominator moves per page.
'categories:accessibility': [
'warn',
{ aggregationMethod: 'median', minScore: 0.9 },
],
},
},
},
};
hard and soft are deliberately identical objects with different names. Both assert minScore: 1, because these audits are binary — there is no “80% of images have alt text” state to threshold. The only thing that differs is the level, and giving the two option objects distinct names makes the diff readable when a reviewer asks why an audit moved between tiers.
Validation
Never trust an audit ID from a document, including this one — Lighthouse renames and retires audits between major versions, and an assertion against an ID that no longer exists is silently satisfied by nothing. Collect once, then read the actual catalogue out of the report.
# Collect a single run so the assertion list can be checked against reality.
npx lhci collect --numberOfRuns=1 --url=http://127.0.0.1:4173/checkout/cart/
# Print every accessibility audit in this Lighthouse version, with its weight
# and score display mode. Anything with mode "manual" cannot be asserted.
node -e '
const fs = require("fs");
const f = fs.readdirSync(".lighthouseci").find((n) => n.startsWith("lhr-"));
const lhr = JSON.parse(fs.readFileSync(".lighthouseci/" + f, "utf8"));
for (const ref of lhr.categories.accessibility.auditRefs) {
const a = lhr.audits[ref.id];
console.log(
[ref.id, "w=" + ref.weight, a.scoreDisplayMode, "score=" + a.score].join(" | ")
);
}'
Then assert against the collected JSON — no browser, no rebuild — and confirm two things: that every audit you named appears in the output, and that nothing you did not name is being asserted.
npx lhci assert # re-reads .lighthouseci/, exits 1 on any failed error assertion
# Expected shape when the config is right and one page has a real failure:
#
# 1 result(s) for http://127.0.0.1:4173/checkout/cart/
#
# X color-contrast failure for minScore assertion
# expected: >=1
# found: 0
# all values: 0, 0, 0, 0, 0
#
# Assertion failed. Exiting with status code 1.
The all values line is the validation that matters most. Five identical zeros mean a genuine, reproducible failure. A line reading 0, 1, 1, 1, 1 means one run out of five disagreed, and that audit does not belong on error yet — move it to warn, watch it for a sprint, and promote it once it stops flickering. With includePassedAssertions: true the same output lists the passing assertions, which is the only way to notice that video-caption has been “passing” for six months on a page that contains no video.
Edge Cases and Conditional Guards
- Not-applicable audits report as neither pass nor fail. An audit with
scoreDisplayMode: 'notApplicable'hasscore: nulland is excluded from both the category average and, in practice, from a meaningful assertion result. If a criterion matters on a page that currently has nothing to check, the guarantee has to come from a rule-level scan of the component, not from a page-level audit that finds no nodes. - Emulation changes the AA verdict.
target-sizeandcolor-contrastboth depend on what painted. Lighthouse defaults to a 412 by 823 mobile viewport at a device pixel ratio of 1.75, so a desktop-only layout is audited at a width it never ships at, and a nav collapsed behind a menu button hides every link fromlink-name. Fix the emulation incollect.settingsand keep it fixed; changing it later moves scores with no code change. - Composite widgets that render children on demand.
aria-required-childrenfails arole="tablist"whose tabs mount after hydration, and arole="tree"that virtualises its items. This is the clearest case forwarnplus a component-level check of the kind described in component-specific rule writing, because the audit is right about the DOM it saw and wrong about the component.
Pipeline Impact
Splitting the assertion list into tiers changes what the exit code means. lhci assert exits non-zero only when an error assertion fails, so the sixteen error audits above are the literal merge contract and everything on warn is advisory text in the log. That makes the gate defensible in a review: a blocked pull request always has a named audit and a failing node behind it, never a two-point score drift.
Expect the tier list to be a living file. Promote an audit from warn to error when its all values line has been uniform for a sprint, and demote one the first time it produces a false block rather than arguing about it — a rule that blocks wrongly once costs more trust than it protects. Because the assertions are keyed on audit IDs and not on a preset, a Lighthouse upgrade can only ever remove coverage silently, never add a surprise blocker; make re-running the audit-catalogue dump above part of the upgrade checklist. When the same page also runs a rule-level scanner, the two will report different totals for the same markup, and Lighthouse score vs axe violation counts explains how to reconcile them before anyone files a bug against the tooling. For the broader question of which engine should own the block, see axe-core vs Lighthouse CI for PR gating; for how a per-audit list ratchets over time, see progressive threshold management.
Common Pitfalls
- Writing
'categories:accessibility': ['error', { minScore: 1 }]and describing the job as WCAG 2.2 AA enforcement, when the score contains non-WCAG audits and omits most of AA. - Combining an explicit assertion list with
preset: 'lighthouse:recommended', which re-adds every performance, SEO and best-practices audit aterrorbehind your back. - Asserting a
minScorebelow 1 on a binary audit — there is no partial credit inimage-alt, sominScore: 0.9is identical tominScore: 1and only misleads the next reader. - Putting
target-sizeonerrorbecause SC 2.5.8 is a real AA criterion, then discovering that the failure count moves with the web font’s load order. - Copying audit IDs between Lighthouse major versions without dumping
auditRefsfirst, leaving assertions that match nothing and quietly assert nothing. - Treating the eleven manual audits as a gap in the configuration rather than as a list of things to test by hand.
FAQ
Is a Lighthouse accessibility score of 1.0 evidence of WCAG 2.2 AA conformance? No, and the gap is not marginal. Fifteen of the 56 Level A and AA criteria have any Lighthouse audit behind them, and of the six criteria added in WCAG 2.2 only SC 2.5.8 Target Size (Minimum) is covered. A 1.0 is evidence that a specific list of machine-checkable audits found nothing on the URLs you collected, at the viewport you emulated — useful, and much narrower than a conformance claim.
Which audit should be the first error assertion in a new pipeline?
color-contrast and image-alt, in that order. Both map to criteria teams already agree on (SC 1.4.3 and SC 1.1.1), both are binary, and both produce failures a developer can fix in the same commit that caused them. Once those two have been green for a sprint, add the aria-* trio and the naming audits; a gate that starts with two credible rules ends up broader than one that starts with twenty contested ones.
How should the manual audits be handled if they cannot be asserted?
Treat them as the specification for a human checklist and record it outside the pipeline. focus-traps, managed-focus, logical-tab-order and visual-order-follows-dom name exactly the AA-adjacent behaviours a static audit cannot see, so a release checklist that walks a keyboard through the four highest-traffic flows covers more real risk than any additional assertion. Route the findings to a tracker, not to lighthouserc.js.
Related
- Lighthouse CI Baseline Configuration — the parent guide on
collect, median aggregation and baseline storage. - Lighthouse Score vs axe Violation Counts — why the same page reports two different totals, and which one to gate on.
- Web Accessibility Testing Fundamentals & Tool Selection — the section that places score budgets alongside rule-level scanners.