Auditing Focus Indicator Contrast for WCAG 2.4.11

Permalink to "Auditing Focus Indicator Contrast for WCAG 2.4.11"

WCAG 2.4.11 Focus Appearance (Level AA, added in WCAG 2.2) requires a keyboard focus indicator to be both large enough and different enough to be unmistakable. It prevents the specific failure of a technically-present-but-imperceptible focus ring — a 1px outline the same tone as the border it replaces — that passes every other check yet leaves keyboard users unable to see where they are. This guide gives the exact numeric requirements and a script that captures focused and unfocused states and computes both conditions, so you can gate the criterion in continuous integration.

It is the measurement companion to focus indicator contrast auditing, which covers the three-criterion picture; this page is only 2.4.11.


Spec reference

Permalink to "Spec reference"

2.4.11 Focus Appearance is a Level AA success criterion introduced in WCAG 2.2. When a UI component receives keyboard focus, the focus indicator must satisfy both of the following:

  • Minimum area. The area of the focus indicator is at least as large as the area of a 2 CSS pixel thick perimeter of the unfocused component (or of a minimum bounding box enclosing it). The normative note gives an equivalent: a 4 CSS pixel thick line along the shortest side of the minimum bounding box.
  • Minimum change contrast. The focus indicator has a contrast ratio of at least 3:1 between the pixels in the focused and unfocused states, for the pixels that change.

There are exceptions — where the focus indicator is determined by the user agent and cannot be adjusted by the author, or where the indicator and its background are not modified by the author — but for a custom data grid where you style focus yourself, neither applies. The default value everywhere absent an author focus style is the user-agent outline, which itself must be evaluated, not assumed compliant.

Two related criteria are frequently confused with this one: 1.4.11 Non-text Contrast (AA) measures the indicator against adjacent colours, and 2.4.7 Focus Visible (A) only requires an indicator to exist. 2.4.11 is the one that measures size and self-change.


When it applies vs. when it does not

Permalink to "When it applies vs. when it does not"

Audit against 2.4.11 when you author a custom focus style — a box-shadow ring, a custom outline, a background swap — on any keyboard-focusable component. Every focusable surface in a custom grid qualifies: cells, header sort buttons, filter inputs, row action controls, pagination.

You may rely on the exception when the indicator is entirely the user agent’s default and you have not modified the component’s or indicator’s colours. In practice most design systems override focus styling, so the exception rarely holds — do not claim it without confirming you truly set no focus-related styles.

The misuse to avoid is treating a passing 1.4.11 contrast check as evidence of 2.4.11 conformance. A ring can contrast beautifully with its surroundings (1.4.11 pass) while being far too thin to meet the area rule (2.4.11 fail). They are orthogonal; measure both.


Annotated code example

Permalink to "Annotated code example"

The focus style that passes by construction

Permalink to "The focus style that passes by construction"
/* SC 2.4.11 area: a 3px outline plus 2px offset covers well beyond the
   2px-perimeter minimum for any realistic component size.
   SC 2.4.11 change contrast: the offset places the ring in pixels that were
   background when unfocused, guaranteeing a large per-pixel change.
   SC 1.4.11: the colour is chosen for ≥3:1 against adjacent surfaces. */
:where([role="gridcell"], [role="columnheader"] button, .grid-control):focus-visible {
  outline: 3px solid var(--focus-ring, #123f1c);
  outline-offset: 2px;
}

Computing the minimum area

Permalink to "Computing the minimum area"
// SC 2.4.11: minimum area = area of a 2 CSS px thick perimeter of the component.
// Perimeter band area ≈ 2 * thickness * (width + height).
// (Corner double-counting is negligible and errs on the strict side.)
function minimumIndicatorArea({ width, height }) {
  const THICKNESS = 2;                     // 2 CSS pixels, per 2.4.11
  return 2 * THICKNESS * (width + height); // square CSS pixels
}

// Equivalent shorthand the spec allows: a 4px line on the shortest side.
function minimumAreaShortSide({ width, height }) {
  return 4 * Math.min(width, height);      // square CSS pixels
}

Capturing both states and isolating the change

Permalink to "Capturing both states and isolating the change"
// SC 2.4.11: capture unfocused and focused pixels over the SAME clip so
// corresponding pixels line up for a per-pixel change-contrast comparison.
import { PNG } from 'pngjs';

async function captureStates(page, handle) {
  // Ensure the element is blurred first (unfocused baseline).
  await page.evaluate(() => document.activeElement?.blur());
  const box = await handle.boundingBox();
  // Pad the clip so an offset ring is not cropped out of the capture.
  const clip = { x: box.x - 6, y: box.y - 6, width: box.width + 12, height: box.height + 12 };

  const unfocused = PNG.sync.read(await page.screenshot({ clip }));   // state A
  await handle.evaluate(el => el.focus());
  const focused = PNG.sync.read(await page.screenshot({ clip }));     // state B
  return { unfocused, focused, box };
}

// Walk both bitmaps; a pixel "changed" if any channel differs beyond noise.
function changedPixels(a, b) {
  const pairs = [];
  for (let i = 0; i < a.data.length; i += 4) {
    const pa = [a.data[i], a.data[i + 1], a.data[i + 2]];
    const pb = [b.data[i], b.data[i + 1], b.data[i + 2]];
    if (pa.some((c, k) => Math.abs(c - pb[k]) > 8)) pairs.push([pa, pb]);
  }
  return pairs;   // each entry is [unfocusedRGB, focusedRGB]
}

Asserting both 2.4.11 conditions

Permalink to "Asserting both 2.4.11 conditions"
// SC 2.4.11: area AND change-contrast must both pass.
function relativeLuminance([r, g, b]) {
  const lin = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
function contrastRatio(a, b) {
  const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
}

function auditFocusAppearance({ box, changed }) {
  // Condition 1 — minimum area (count changed pixels vs the perimeter band).
  const areaPass = changed.length >= minimumIndicatorArea(box);

  // Condition 2 — per-pixel change contrast ≥ 3:1 (focused vs its own unfocused value).
  // Use the median changed pixel so a few anti-aliased edge pixels do not skew it.
  const ratios = changed.map(([u, f]) => contrastRatio(u, f)).sort((a, b) => a - b);
  const median = ratios[Math.floor(ratios.length / 2)] ?? 0;
  const changePass = median >= 3;   // 3:1 threshold

  return { areaPass, changePass, area: changed.length, changeContrast: median };
}

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"

2.4.11 is a visual criterion — assistive-technology speech is irrelevant to it. The behaviour that matters is what renders on keyboard focus, and how the audit reads it.

Trigger State captured What the audit reads Pass condition
Element blurred Unfocused (A) Baseline pixels over the padded clip
.focus() on element Focused (B) Same clip with the indicator rendered Indicator pixels present
Diff A vs B Changed-pixel set Count + per-pixel [unfocused, focused] pairs Count ≥ 2px-perimeter area
Per-pixel contrast Median changed pixel Ratio between focused and unfocused value ≥ 3:1

Note that focus must be moved with the keyboard path (:focus-visible), not a mouse click — a mouse-focus style is often suppressed and would make the audit read an absent indicator.


Integration context

Permalink to "Integration context"

Where focus lands determines which components this audit must cover, which is why it depends on correct focus management in single-page apps: a route change that drops focus to document.body leaves a surface with no indicator to measure and a real 2.4.11 gap. Run this per-element audit across the surface inventory built in the parent focus indicator contrast auditing guide, and pair its results with the 1.4.11 adjacent-contrast check so both AA focus criteria are gated together.


Common data-grid failures

Permalink to "Common data-grid failures"

1. The thin default outline

Permalink to "1. The thin default outline"

A design system sets outline: 1px solid to look tidy. It passes 2.4.7 (visible) and may pass 1.4.11 (contrasts with white), but its rendered area is roughly half the 2px-perimeter minimum, so it fails the area rule. Fix: outline-width: 3px with a 2px offset.

2. Offset ring clipped by overflow: hidden

Permalink to "2. Offset ring clipped by overflow: hidden"

Grid cells often set overflow: hidden to truncate text. An outline-offset ring is clipped at the cell boundary, so the audit measures only the unclipped fragment and the area falls short. Fix: render the ring on an inner element that is not clipped, or remove the offset and thicken the outline.

3. Low change contrast on a bordered control

Permalink to "3. Low change contrast on a bordered control"

A control already has a 1px grey border; the focus style darkens that same border slightly. The changed pixels move from grey to a slightly darker grey — a per-pixel change contrast well under 3:1. Fix: change the indicator in pixels that were previously background (use offset), where the change is background-to-ring and easily clears 3:1.


FAQ

Permalink to "FAQ"
What exactly is the minimum area under WCAG 2.4.11?

The focused-state indicator must cover an area at least as large as a 2 CSS pixel thick perimeter of the unfocused component’s minimum bounding box. The standard gives an equivalent shorthand: a 4 CSS pixel thick line along the shortest side of that bounding box. For a 160 by 40 pixel button the perimeter area is roughly 2 times 2 times 200, which is 800 square CSS pixels, so a 1 pixel outline covering about 400 square pixels fails.

Is the 3:1 change contrast measured against the background?

No. The 3:1 change contrast of 2.4.11 is measured between a pixel in the focused state and that same pixel in the unfocused state. It asks how much each pixel changed when focus arrived, not how the indicator contrasts with its surroundings. The ring-versus-adjacent-colour 3:1 ratio is a separate requirement under 1.4.11 Non-text Contrast.

Why do thin outlines pass axe-core but fail 2.4.11?

axe-core inspects the DOM and computed styles heuristically; it cannot measure the rendered area of an indicator or the per-pixel change between focused and unfocused screenshots. A 1 pixel outline is valid CSS and present in the accessibility tree, so it passes the linter, yet its rendered area falls below the 2px-perimeter minimum, so it fails 2.4.11. Only a pixel-level screenshot audit catches this.


Permalink to "Related"

Back to Focus Indicator Contrast Auditing