Focus Indicator Contrast Auditing

Permalink to "Focus Indicator Contrast Auditing"

A focus indicator that is present in the DOM but too faint, too thin, or too small to see is a WCAG failure that no colour-contrast text check will catch and no outline: none linter rule fully covers. Auditing focus indicators means proving three separate things for every focusable surface: that an indicator appears at all, that it contrasts enough against the colours next to it, and — since WCAG 2.2 — that it is large enough and changes enough between states to be unmistakable. This page defines each requirement, shows how to measure a focus ring against a data grid’s cells, headers, and controls, and gives the scripts to enforce the measurements in continuous integration.

Focus indicators are where three success criteria overlap, and a grid is where they are hardest to satisfy because a single ring may sit against a cell fill, a selected-row highlight, and a header background depending on where focus lands. This audit pairs with the runtime behaviour covered in focus management in single-page apps and the linting layer in automated accessibility testing pipelines.


WCAG Criteria in Scope

Permalink to "WCAG Criteria in Scope"
Criterion Level Relevance to focus auditing
2.4.7 Focus Visible A A keyboard focus indicator must be visible on every focusable element
1.4.11 Non-text Contrast AA The indicator must reach 3:1 contrast against adjacent colours
2.4.11 Focus Appearance AA (WCAG 2.2) The indicator must meet a minimum area and 3:1 change contrast between focused and unfocused states
1.4.1 Use of Color A The indicator must not rely on a colour change alone that some users cannot perceive

The three focus criteria stack: 2.4.7 asks whether an indicator exists, 1.4.11 asks whether it contrasts with its surroundings, and 2.4.11 asks whether it is big and different enough to notice. Passing one does not imply passing the others.


The three requirements, precisely

Permalink to "The three requirements, precisely"

2.4.7 Focus Visible (Level A)

Permalink to "2.4.7 Focus Visible (Level A)"

Any keyboard-operable interface must have a mode of operation where the keyboard focus indicator is visible. This is a binary presence check: focus something with the keyboard and confirm something visibly changes. It says nothing about how strong that change must be — that is what the AA criteria add.

1.4.11 Non-text Contrast (Level AA)

Permalink to "1.4.11 Non-text Contrast (Level AA)"

Visual information required to identify a UI component and its states must have a contrast ratio of at least 3:1 against adjacent colours. For a focus indicator this means the ring’s colour must reach 3:1 against whatever it touches — both the component’s own fill on the inside and the page background on the outside. A ring that clears 3:1 against a white page but only 1.8:1 against a dark selected-row highlight fails when focus lands on a selected row.

2.4.11 Focus Appearance (Level AA, new in WCAG 2.2)

Permalink to "2.4.11 Focus Appearance (Level AA, new in WCAG 2.2)"

The indicator must satisfy two measurable conditions:

  • Minimum area — the focused-state indicator is at least as large as the area of a 2 CSS pixel thick perimeter of the unfocused component (equivalently, a 4 CSS pixel thick line along the shortest side of a minimum bounding box).
  • Minimum change contrast — the pixels that change between the focused and unfocused states have a contrast ratio of at least 3:1 between those two states.

A 1px outline the same colour family as the border it replaces can pass 2.4.7 and still fail 2.4.11 on both counts: too little area and too little change.


Prerequisites

Permalink to "Prerequisites"

This page assumes you can:


Focus indicator anatomy and the area rule

Permalink to "Focus indicator anatomy and the area rule"

The diagram below breaks a focus ring into the parts each criterion measures. The indicator sits at a boundary with two neighbours — the component fill inside and the page background outside — and 1.4.11 measures against both. The area rule of 2.4.11 compares the indicator’s footprint to a 2px-thick band tracing the component’s perimeter.

Focus indicator anatomy and the 2.4.11 area rule A focused button in the centre. An outer focus ring is measured for contrast against the page background outside it and the button fill inside it, satisfying 1.4.11. A callout shows the minimum area rule of 2.4.11: the indicator must be at least the area of a two CSS pixel thick perimeter of the component. A second callout shows the three-to-one change contrast between the focused and unfocused states. Adjacent colour A — page background (outside the ring) Sort ▲ Focus indicator (ring) Adjacent colour B — component fill (inside the ring) 1.4.11: ring ≥ 3:1 vs BOTH A and B 2.4.11 — minimum area Indicator area ≥ area of a 2px-thick perimeter around the component… …or a 4px line on its shortest side. A 1px outline fails this rule. 2.4.11 — change contrast The pixels that change between the unfocused and focused states must reach ≥ 3:1 between the two states. Measured focused-pixel vs same unfocused pixel. 2.4.7 (Level A): an indicator must be present at all. 1.4.11 + 2.4.11 (AA): it must be strong and large enough to notice.

Measurement reference

Permalink to "Measurement reference"

Each criterion is measured differently. Getting the inputs right is most of the work; the ratios themselves are arithmetic.

Contrast against adjacent colours (1.4.11)

Permalink to "Contrast against adjacent colours (1.4.11)"

Sample the indicator’s colour, then sample each colour it borders, and compute the WCAG contrast ratio for each pair. The lowest pair is the one that must clear 3:1. In a grid the neighbours vary by context: a header sort button’s ring borders the header background; a selected row’s cell ring borders the selection highlight. Audit each context, not just the default cell.

Change contrast (2.4.11)

Permalink to "Change contrast (2.4.11)"

Capture the element’s pixels unfocused, capture the same pixels focused, and compute the contrast between corresponding pixels. This is not the ring-versus-background ratio — it is focused-pixel versus its own unfocused value. A ring that appears in previously-background pixels trivially passes; a ring that merely darkens an already-dark border may not.

Area (2.4.11)

Permalink to "Area (2.4.11)"

Compute the indicator’s covered area in CSS pixels and compare it to a 2px-thick perimeter of the component’s bounding box (2 × (width + height) × 2px, minus corner overlap), or use the 4px-shortest-side shorthand. Thin 1px outlines and 1px box-shadow rings almost always fail here even when their contrast is fine.


Step-by-Step Implementation

Permalink to "Step-by-Step Implementation"

Step 1 — Inventory every focusable surface (WCAG 2.4.7)

Permalink to "Step 1 — Inventory every focusable surface (WCAG 2.4.7)"

An audit that only checks the sort button misses the cells, the filter input, and the row action menu. Enumerate every focusable element in the grid programmatically so no surface is skipped.

// SC 2.4.7: every focusable surface needs a visible indicator — find them all.
function focusableSurfaces(root) {
  const selector = [
    'a[href]', 'button', 'input', 'select', 'textarea',
    '[tabindex]:not([tabindex="-1"])',
    '[role="gridcell"][tabindex="0"]',   // roving-tabindex active cell
    '[role="columnheader"] button',      // sort triggers
  ].join(',');
  return Array.from(root.querySelectorAll(selector))
    .filter(el => !el.disabled && el.offsetParent !== null);
}

Step 2 — Assert an indicator actually appears (WCAG 2.4.7)

Permalink to "Step 2 — Assert an indicator actually appears (WCAG 2.4.7)"

Focus each surface and confirm a computed-style change occurs on :focus-visible. The reliable signal is that at least one of outline, box-shadow, border, or background-color differs between the blurred and focused states.

// SC 2.4.7: prove a visible change occurs on keyboard focus, not just DOM focus.
async function hasVisibleIndicator(page, handle) {
  const before = await handle.evaluate(el => {
    const s = getComputedStyle(el);
    return { outline: s.outlineStyle + s.outlineWidth, shadow: s.boxShadow, bg: s.backgroundColor, border: s.borderColor };
  });
  await handle.evaluate(el => el.focus());
  const after = await page.evaluate(el => {
    const s = getComputedStyle(el);
    return { outline: s.outlineStyle + s.outlineWidth, shadow: s.boxShadow, bg: s.backgroundColor, border: s.borderColor };
  }, handle);
  // Any differing property means a visible change was rendered.
  return JSON.stringify(before) !== JSON.stringify(after);
}

Step 3 — Design the indicator to pass area and contrast (WCAG 1.4.11, 2.4.11)

Permalink to "Step 3 — Design the indicator to pass area and contrast (WCAG 1.4.11, 2.4.11)"

Author the focus style so it clears both AA criteria by construction: a thick, high-contrast ring offset from the component so it borders the background cleanly. A double-ring (dark + light) survives both light and dark neighbours.

/* SC 1.4.11: ring ≥ 3:1 against adjacent colours.
   SC 2.4.11: thickness gives the area; offset guarantees change contrast. */
:where(button, [role="gridcell"], input):focus-visible {
  outline: 3px solid var(--focus-ring, #1c5b28);   /* ≥ 2px → area rule */
  outline-offset: 2px;                              /* separates ring from fill */
  /* Second contrasting ring survives dark selected-row backgrounds too. */
  box-shadow: 0 0 0 5px var(--focus-ring-halo, #ffffff);
}
/* SC 1.4.1: never rely on a hue change alone — thickness/offset carry meaning. */

Step 4 — Measure contrast against adjacent colours (WCAG 1.4.11)

Permalink to "Step 4 — Measure contrast against adjacent colours (WCAG 1.4.11)"

Sample the ring and each neighbour, then compute the ratio. Fail the lowest pair below 3:1.

// SC 1.4.11: focus ring must reach 3:1 against every colour it borders.
function relativeLuminance([r, g, b]) {
  const lin = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
  const [R, G, B] = [lin(r), lin(g), lin(b)];
  return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
function contrastRatio(a, b) {
  const L1 = relativeLuminance(a), L2 = relativeLuminance(b);
  const [hi, lo] = L1 > L2 ? [L1, L2] : [L2, L1];
  return (hi + 0.05) / (lo + 0.05);
}
// ringRGB sampled from the indicator; neighbours = [background, componentFill, selectionHighlight]
function passesAdjacent(ringRGB, neighbours) {
  const worst = Math.min(...neighbours.map(n => contrastRatio(ringRGB, n)));
  return { ratio: worst, pass: worst >= 3 };   // 3:1 threshold
}

Step 5 — Measure area and change contrast in CI (WCAG 2.4.11)

Permalink to "Step 5 — Measure area and change contrast in CI (WCAG 2.4.11)"

Screenshot the element unfocused and focused, diff the pixels to isolate the indicator, then check both its covered area and the per-pixel change contrast. This is the crux of the WCAG 2.4.11 audit, covered in full on the deep-dive page.

// SC 2.4.11: area ≥ 2px-perimeter, and changed pixels ≥ 3:1 between states.
async function auditFocusAppearance(page, handle) {
  const box = await handle.boundingBox();
  const unfocused = await handle.screenshot();          // state A
  await handle.evaluate(el => el.focus());
  const focused = await page.screenshot({ clip: box }); // state B (same clip)

  const changed = diffChangedPixels(unfocused, focused); // {count, samplePairs}
  const perimeterArea = 2 * 2 * (box.width + box.height); // 2px-thick perimeter
  const areaPass = changed.count >= perimeterArea;

  const changePass = changed.samplePairs.every(
    ([a, b]) => contrastRatio(a, b) >= 3               // focused vs unfocused pixel
  );
  return { areaPass, changePass };
}

Keyboard & measurement behaviour

Permalink to "Keyboard & measurement behaviour"
Surface in the grid Focus trigger Adjacent colours to test Common failure
Header sort button Tab Header background, button fill Ring only tested on white; fails on tinted header
Data cell (roving) Arrow keys Cell fill, row-hover, selection highlight Ring invisible on selected-row background
Filter input Tab Toolbar background, input border 1px outline fails 2.4.11 area rule
Row action button Tab Row background, alternating-row stripe Change contrast fails on the darker stripe
Pagination control Tab Footer background outline: none with no replacement fails 2.4.7

Screen reader and tooling compatibility

Permalink to "Screen reader and tooling compatibility"

Focus contrast is a visual criterion, so screen readers are not the audit surface — the browser’s rendering is. But the tooling that captures it varies, and each tool measures a different subset.

Tool / method Measures 2.4.7 Measures 1.4.11 Measures 2.4.11 area Notes
axe-core rules Partial No No Flags outline:none heuristically; cannot measure rendered contrast or area
Manual eyedropper Yes Yes No Accurate for one state; area is hard to judge by eye
Playwright screenshot diff Yes Yes Yes The only method that measures rendered area and per-pixel change contrast
Browser devtools contrast No Yes (text) No Its contrast tool targets text, not non-text indicators

The takeaway: linters cannot certify focus appearance. Only a pixel-level screenshot audit measures the 2.4.11 area and change-contrast rules, which is why Step 5 and the deep-dive script exist.


Edge Cases & Failure Modes

Permalink to "Edge Cases & Failure Modes"

1. The ring passes on white but fails on a selected row

Permalink to "1. The ring passes on white but fails on a selected row"

A grid highlights the selected row with a darker fill. A focus ring tuned for 3:1 against the white default cell may drop below 3:1 against that highlight. Fix: use a two-tone ring (dark core plus light halo) so one tone always contrasts, and test focus on a selected row explicitly.

2. outline-offset pushing the ring off a clipped cell

Permalink to "2. outline-offset pushing the ring off a clipped cell"

overflow: hidden on a grid cell clips an offset outline, silently erasing part of the indicator and shrinking its measured area below the 2.4.11 threshold. Fix: apply the focus ring to an inner wrapper that is not clipped, or use box-shadow (which is not clipped by the element’s own overflow the way an outline can be by an ancestor).

3. Windows High Contrast Mode replacing your ring

Permalink to "3. Windows High Contrast Mode replacing your ring"

Forced-colours modes override custom outlines with system colours; a box-shadow-only ring disappears entirely because shadows are not rendered in forced-colours mode. Fix: always include an outline (which forced-colours modes preserve) as the load-bearing indicator, with box-shadow only as an enhancement.

4. Sub-pixel thin rings on high-DPI displays

Permalink to "4. Sub-pixel thin rings on high-DPI displays"

A 1px outline on a 2× display is a physical 2 device pixels but still 1 CSS pixel — 2.4.11 measures CSS pixels, so it still fails the area rule. Fix: specify at least 2px (preferably 3px) in CSS regardless of device pixel ratio.

5. Auditing only the default state

Permalink to "5. Auditing only the default state"

Focus appearance must hold in every visual state a component can be in: default, hover, selected, error, disabled-adjacent. An audit that captures only the resting state passes components that fail once a row is selected or an error border is applied. Fix: parametrise the audit over each state class.


Deep dive: auditing against WCAG 2.4.11

Permalink to "Deep dive: auditing against WCAG 2.4.11"

The exact numeric requirements of 2.4.11 Focus Appearance — the 2px-perimeter area equivalence, the 3:1 change-contrast measurement, the minimum bounding box, and a complete capture-and-compute script — are covered in the dedicated guide: auditing focus indicator contrast for WCAG 2.4.11.

Key facts from that page:

  • The minimum area is the area of a 2 CSS pixel thick line tracing the component’s perimeter, which the standard also expresses as a 4px line along the shortest side of the bounding box.
  • The 3:1 change contrast is measured between a focused pixel and the same pixel unfocused — not between the ring and the background.
  • The most common data-grid failure is a thin, low-offset outline that clears 1.4.11 but fails the 2.4.11 area rule.
// Minimal shape — full annotated version in the deep-dive guide.
// SC 2.4.11: the two AA conditions in one predicate.
function passes2411({ indicatorAreaPx, componentBox, changePairs }) {
  const minArea = 2 * 2 * (componentBox.width + componentBox.height);
  const areaOk = indicatorAreaPx >= minArea;
  const changeOk = changePairs.every(([a, b]) => contrastRatio(a, b) >= 3);
  return areaOk && changeOk;
}

Testing Checklist

Permalink to "Testing Checklist"

Inventory

Permalink to "Inventory"

2.4.7 Focus Visible

Permalink to "2.4.7 Focus Visible"

1.4.11 Non-text Contrast

Permalink to "1.4.11 Non-text Contrast"

2.4.11 Focus Appearance

Permalink to "2.4.11 Focus Appearance"

Automation

Permalink to "Automation"

Permalink to "Related"

Back to Testing & Auditing Accessible Data Interfaces