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.

What to capture, and why Matrix of four capture conditions with the defect each one is designed to catch. What to capture, and whyCatchesLight theme,unfocusedthe baseline colour to measure againstLight theme, focuseda ring that is too thin or too low contrastDark theme, focuseda ring tuned for light backgrounds onlyFocused on a stripedrowa ring that clears the page but not the stripe
Two themes and two states is four frames per control — cheap, and it catches everything.

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.

The screenshot pipeline Five-stage pipeline: enumerate focusable controls, focus each one, capture unfocused and focused frames, compare, and report the ratio. The screenshot pipelineEnumerateevery focusablecontrolFocusone at a time, bykeyboardCaptureunfocused andfocused framesDifflocate the changedpixelsReportcontrast ratio percontrol
Automating the capture is what makes a focus audit repeatable.

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.

Measuring the ratio from two frames Three-step measurement: diff the two frames to find the indicator pixels, sample the adjacent unfocused colour, and compute the ratio. Measuring the ratio from two framesDiff the frameschanged pixels are the indicatorNo need to know where it was drawnSample the neighboursthe unfocused colour immediately outsideThe row stripe, not the page backgroundCompute and recordratio per control, pass or fail at 3:1Store it so regressions are visible
The diff finds the indicator; the neighbouring pixels are what it must contrast against.

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.

Permalink to "Related"

Back to Focus Indicator Contrast Auditing