Automating Focus Indicator Screenshots With Playwright
Permalink to "Automating Focus Indicator Screenshots With Playwright"Focus indicator contrast is the one accessibility requirement that no DOM-based rule engine can evaluate. Whether a ring is visible depends on what was painted: its width after outline-offset, its colour against the specific row it landed on, and whether an ancestor’s overflow: hidden clipped it. The only way to check it reliably is to look at pixels — which is a job for a headless browser rather than a person. This page covers the capture pipeline, the measurement, and what to assert on.
It automates the manual method described in auditing focus indicator contrast for WCAG 2.4.11, and it fits into the pipeline described in automated accessibility testing pipelines.
Spec reference
Permalink to "Spec reference"SC 2.4.11 Focus Appearance (Level AAA in WCAG 2.2, and a Level AA requirement in several procurement standards) asks that the focus indicator has an area at least equal to a 2 CSS pixel perimeter of the control, and a contrast ratio of at least 3:1 between the focused and unfocused states of that area.
Two details make it hard to check statically. First, the ratio is between the focused and unfocused states of the same pixels — not between the ring and the page background. Second, “not obscured” is part of the requirement, and obscuring is a paint-time property.
SC 2.4.7 Focus Visible (Level AA) is the simpler companion: some visible indicator must exist. A capture pipeline checks both at once, because a control whose two frames are identical has no indicator at all.
When to use — and when not to
Permalink to "When to use — and when not to"Run this on every distinct control design in the product, in both themes. It is fast enough to run per pull request when scoped to a component gallery, and it belongs in the merge build if it is scoped to full pages.
Do not run it on every instance of a control. Four hundred identical row checkboxes produce four hundred identical measurements and a slow job.
Do not use it as a replacement for a keyboard walkthrough. It measures the indicator; it says nothing about whether the focus order makes sense or whether every control is reachable.
Annotated code example
Permalink to "Annotated code example"// SC 2.4.11: measure what was PAINTED, not what the CSS says
import { test, expect } from '@playwright/test';
import { PNG } from 'pngjs';
const THRESHOLD = 3.0; // 3:1 between focused and unfocused
async function measureIndicator(page, selector) {
const el = page.locator(selector);
const box = await el.boundingBox();
// Pad the clip so the ring and its immediate surroundings are included
const clip = { x: box.x - 6, y: box.y - 6, width: box.width + 12, height: box.height + 12 };
await page.evaluate(() => document.activeElement?.blur());
const unfocused = PNG.sync.read(await page.screenshot({ clip }));
await el.focus(); // focus by API, then verify by keyboard
const focused = PNG.sync.read(await page.screenshot({ clip }));
return contrastOfChangedPixels(unfocused, focused);
}
test.describe('focus indicators', () => {
for (const scheme of ['light', 'dark']) {
test(`row checkbox, ${scheme} theme`, async ({ page }) => {
await page.emulateMedia({ colorScheme: scheme });
await page.goto('/components/data-grid');
const ratio = await measureIndicator(page, '[data-testid="row-checkbox"]');
expect(ratio, `focus ring contrast in ${scheme} theme`).toBeGreaterThanOrEqual(THRESHOLD);
});
}
});
// The measurement: changed pixels ARE the indicator; their neighbours are the baseline
function contrastOfChangedPixels(before, after) {
const changed = [];
for (let i = 0; i < before.data.length; i += 4) {
const d = Math.abs(before.data[i] - after.data[i])
+ Math.abs(before.data[i + 1] - after.data[i + 1])
+ Math.abs(before.data[i + 2] - after.data[i + 2]);
if (d > 12) changed.push(i); // tolerance for antialiasing
}
if (!changed.length) return 0; // no indicator at all — a hard fail
const ring = dominantColour(after, changed);
const neighbour = dominantColour(before, changed); // same pixels, unfocused
return contrastRatio(ring, neighbour);
}
Measuring the same pixels in both frames is the part that makes this correct. Comparing the ring against the page background gives a flattering number on a striped table, because the stripe is darker than the page and the stripe is what the ring actually sits on.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Check | Why it matters | Failure indicator |
|---|---|---|
| Focus by keyboard, not only by API | :focus-visible styles may not apply to programmatic focus |
The ring is missing in the capture but present in real use |
| Both colour schemes | A ring tuned for light backgrounds vanishes on dark | Passes in light, fails in dark |
| On a striped row | The adjacent colour differs from the page background | Passes on white, fails on the stripe |
| Inside a scroll container | overflow: hidden clips the ring |
Partially captured ring, low measured area |
The first row is the one that silently invalidates a whole suite. element.focus() does not always trigger :focus-visible, so a page styled only for :focus-visible can measure as having no indicator. Drive focus with page.keyboard.press('Tab') where the tab order allows it, or add :focus styles as well.
Integration context
Permalink to "Integration context"This suite belongs beside the axe-core runs described in setting up axe-core in a GitHub Actions pipeline, and it complements them exactly: axe checks what the DOM says, this checks what the renderer did.
The controls most worth covering in a data interface are the ones the focus indicator contrast auditing guide identifies as usual failures: column header buttons, resize grips, row checkboxes, inline-edit cells and pagination controls. The resize grip from keyboard-accessible column resizing is the single most likely failure, because it is thin and frequently clipped.
Gotchas
Permalink to "Gotchas"Antialiasing noise. A tolerance of around 12 on the summed channel difference filters font antialiasing without missing a real ring.
Animated focus rings. A ring that transitions in will be captured mid-animation. Disable transitions in the test environment.
Screenshot scale. A device pixel ratio above 1 changes the pixel counts; fix it in the Playwright config so measurements are comparable across machines.
Asserting on images. Image baselines churn on every unrelated rendering change. Assert on the computed ratio.
FAQ
Permalink to "FAQ"Can axe-core check focus indicator contrast?
No. Automated rule engines evaluate the DOM and computed styles; a focus indicator is a rendered result that depends on outline width, offset, colour, the element behind it and whatever clips it. Nothing in the DOM tells you the ratio. That is why the screenshot approach exists — it measures what was actually painted.
How many controls should be captured?
Every distinct control type, not every instance. A table with 412 rows has one row-checkbox design, so capture one. What matters is coverage of designs and of contexts: the same checkbox on a striped row and on a plain row are two contexts, and they can produce different results.
Should the screenshots be committed as baselines?
Commit the measured ratios rather than the images. Ratios are small, diff cleanly in review, and do not churn on unrelated rendering changes. Keep the images as build artefacts for debugging a failure, but assert on the numbers.
Related
Permalink to "Related"- Focus indicator contrast auditing — the parent guide and the manual method
- Auditing focus indicator contrast for WCAG 2.4.11 — what the numbers mean
- Writing a screen reader smoke test with Playwright — the other Playwright suite in this pipeline