Testing forced-colors Mode in CI with Playwright

Part of forced-colors & High Contrast Mode. Manual high-contrast testing happens once before a release, if at all. These assertions run on every pull request and catch the three defects that actually occur.

Three automatable forced-colors defects Three failure classes that can be detected programmatically: an element distinguished only by background colour, a focus ring built from a theme token, and an image or SVG that disappears against the forced background. Background-only signal a selected row differs from its neighbours by fill alone detect: computed background identical after forcing fix: add a border or a mark Vanished focus ring outline colour came from a theme token, now overridden detect: outline width zero or colour equals background fix: system colour fallback Invisible graphic a white logo on a white forced canvas detect: screenshot the region and check for any ink fix: forced-color-adjust: none
All three are detectable without a human looking at the page, which is what makes them worth automating.

Prerequisites Link to this section

  • Playwright with a Chromium project; forcedColors emulation is Chromium-only at the time of writing.
  • Components reachable at stable URLs, ideally the same gallery used for snapshot testing.
  • A list of the components where state is communicated visually — selection, validity, activity — since those are where the defects concentrate.
  • The forced-colors rules already written, as covered in supporting Windows high contrast.

Step-by-Step Implementation Link to this section

Step 1 — Emulate forced colours at the project level Link to this section

Intent: run a whole project’s tests under forced colours rather than toggling it per test.

// playwright.config.mjs
export default {
  projects: [
    { name: 'default', use: { browserName: 'chromium' } },
    {
      name: 'forced-colors',
      testMatch: /.*\.forced\.spec\.mjs/,
      use: { browserName: 'chromium', forcedColors: 'active', colorScheme: 'dark' },
    },
  ],
};

Why this works: a separate project keeps the forced-colors assertions in their own files, which is important because they are different assertions rather than the same ones under a different setting. Pairing forcedColors: 'active' with a dark colorScheme approximates the most common real configuration — a Windows contrast theme with a dark canvas — which is where most defects show.

Step 2 — Assert that state is not carried by background alone Link to this section

Intent: catch the most common defect directly, by comparing computed backgrounds.

// tests/table.forced.spec.mjs
import { test, expect } from '@playwright/test';

test('a selected row remains distinguishable', async ({ page }) => {
  await page.goto('/gallery/data-table/');

  const selected = page.locator('tr[aria-selected="true"]').first();
  const plain = page.locator('tr[aria-selected="false"]').first();

  const [a, b] = await Promise.all([
    selected.evaluate((el) => {
      const s = getComputedStyle(el);
      return { bg: s.backgroundColor, outline: s.outlineWidth, border: s.borderLeftWidth };
    }),
    plain.evaluate((el) => {
      const s = getComputedStyle(el);
      return { bg: s.backgroundColor, outline: s.outlineWidth, border: s.borderLeftWidth };
    }),
  ]);

  const differsSomehow =
    a.bg !== b.bg || a.outline !== b.outline || a.border !== b.border;

  expect(differsSomehow, 'selected row is indistinguishable under forced colours').toBe(true);
});

Why this works: the assertion is about difference rather than about a specific value, which is what makes it robust across contrast themes. Under forced colours the two backgrounds usually collapse to the same system colour, so the test passes only if the component also expresses selection through a border, an outline or an added mark.

Step 3 — Assert the focus ring survives Link to this section

Intent: keyboard operability is the single most important thing to preserve in this mode.

test('focus is visible on every interactive element', async ({ page }) => {
  await page.goto('/gallery/form/');

  const controls = page.locator('button, a[href], input, select, textarea');
  const count = await controls.count();

  for (let i = 0; i < count; i++) {
    const el = controls.nth(i);
    await el.focus();
    const ring = await el.evaluate((node) => {
      const s = getComputedStyle(node);
      return {
        width: parseFloat(s.outlineWidth) || 0,
        style: s.outlineStyle,
        color: s.outlineColor,
      };
    });
    expect(ring.width, `no focus outline on control ${i}`).toBeGreaterThan(0);
    expect(ring.style).not.toBe('none');
  }
});

Why this works: it iterates real focusable elements rather than checking one representative, which catches the case where most controls are fine and one custom widget is not. Asserting on outline width and style rather than colour keeps the test valid whatever system colour the contrast theme supplies.

What emulation covers and what it cannot A comparison of Playwright forced-colors emulation against a real Windows contrast theme, listing the defect classes each one can and cannot detect. Emulation catches background-only state signalling focus rings that disappear borders removed by the UA shadows relied on for separation runs on every pull request Only a real theme catches Highlight vs ButtonFace collisions unusual palettes (Desert, Aquatic) OS-level rendering differences how the whole page reads together run once per release
Emulation is not a substitute for a real contrast theme; it is what makes the manual check rare enough to actually happen.

Step 4 — Screenshot to catch invisible graphics Link to this section

Intent: an image or SVG that disappears cannot be detected from computed styles.

test('the logo remains visible under forced colours', async ({ page }) => {
  await page.goto('/');
  const logo = page.locator('.site-logo svg');
  const shot = await logo.screenshot();

  // Any two distinct pixel values means something is drawn.
  const distinct = new Set();
  for (let i = 0; i < shot.length; i += 4) {
    distinct.add(`${shot[i]},${shot[i + 1]},${shot[i + 2]}`);
    if (distinct.size > 1) break;
  }
  expect(distinct.size, 'logo renders as a single flat colour').toBeGreaterThan(1);
});

Why this works: a completely flat region means the graphic has either vanished or been forced to a single colour, which are the same problem from a reader’s point of view. This is a much cheaper and more stable check than a baseline comparison, because it asserts a property rather than an appearance and therefore never needs updating.

Step 5 — Keep the suite small and targeted Link to this section

Intent: forced-colors assertions are most valuable on a handful of components, and diluting them across the whole library reduces the signal.

Cover the components where state is communicated visually — tables with selection, form fields with validity, toggles, tabs, badges — plus the global focus ring assertion. That is typically eight to ten tests, runs in well under a minute, and catches essentially every regression this mode produces. Adding the other hundred components produces passing tests that assert nothing interesting.

Verification Link to this section

Prove each assertion can fail. Temporarily remove the border from the selected row style and confirm the first test fails; remove the system-colour focus ring fallback and confirm the second fails; set the logo to forced-color-adjust: auto and confirm the third fails.

That exercise takes ten minutes and is the difference between a suite that protects the behaviour and one that has been passing since the day the rules were written for reasons nobody has checked. It is worth repeating whenever a new assertion is added, because forced-colors tests are unusually easy to write in a way that can never fail.

Assertion styles and their durability Three assertion styles compared: exact colour values which break with every contrast theme, difference-based assertions which hold across themes, and screenshot baselines which need maintenance. exact colour assertion expect(bg).toBe('rgb(0, 0, 0)') — breaks under every contrast theme but one difference assertion expect(a).not.toBe(b) — holds under any palette, tests the property that matters screenshot baseline correct but high-maintenance; reserve for the invisible-graphic case
Assert differences, not values. The system colours are supplied by the visitor's theme and are not yours to predict.

Troubleshooting Link to this section

Symptom Likely cause Fix
Tests pass with the forced-colors rules deleted Assertions check values the UA does not actually force Rewrite as difference assertions and re-prove they can fail
Emulation appears to do nothing The project is not Chromium, or forcedColors is set per-test after navigation Set it in the project use block; other engines do not support it
The focus test fails on a custom widget only The widget draws its own focus indicator with a theme colour Add a system-colour fallback inside a forced-colors media query
Screenshot pixel check is flaky The region includes antialiased edges that vary Screenshot a solid interior region, or compare distinct-colour count rather than pixels
Everything passes and the real theme still looks wrong Emulation does not model palette pairings Keep a per-release manual pass on a real contrast theme

Migration Note Link to this section

Adding these tests to an existing product usually finds two or three genuine defects immediately, and they cluster in predictable places: a selected state that uses only a background tint, and a focus ring that was carefully themed and therefore disappears.

Fix the focus ring first, because it affects every keyboard user in the mode and the fix is one rule. Then work through the state indicators, which are more involved — adding a border or an icon to a selected row is a visual design change that needs review in the default theme too, since the addition is visible there as well.

Resist the temptation to solve state signalling with forced-color-adjust: none, which opts an element out of the mode entirely and hands back responsibility for contrast to your palette — the opposite of what a visitor using a contrast theme asked for. Reserve it for graphics where the original colours genuinely carry information, such as a logo or a photograph.

Why This Mode Is Worth Automating At All Link to this section

Forced-colors support is easy to deprioritise: the user base is small, the mode is unfamiliar to most developers, and nothing about a normal review surfaces a defect in it. Three arguments justify the handful of tests.

The first is that the defects are severe rather than cosmetic. A focus ring that disappears makes a form unusable by keyboard; a selection state carried only by background colour makes a table unreadable. These are not degraded experiences but blocked ones, for exactly the visitors who selected a contrast theme because they need it.

The second is that the fixes are cheap and durable. A system-colour focus ring fallback is three lines and never needs revisiting. A selected row that also carries a border is a small design change that improves the default theme too. Almost nothing about supporting this mode is expensive once the defects are known — the expense is entirely in discovering them.

The third is that automation converts an annual, easily-skipped ritual into a continuous guarantee. The manual pass on a real contrast theme still matters and still catches things emulation cannot, but it can be rare precisely because the regressions that would otherwise accumulate between passes are caught the day they are introduced.

There is also a practical benefit that has nothing to do with this mode. Components that communicate state through more than colour are more robust everywhere — in greyscale printing, on failing displays, for readers with colour vision deficiencies, and in the screenshot someone pastes into a ticket. Forced-colors testing is an unusually cheap way to find the places where that discipline lapsed.