Forced-Colors Screenshots with Playwright

Permalink to "Forced-Colors Screenshots with Playwright"

Windows contrast themes (formerly High Contrast mode) replace author colours with a small set of system colours. Backgrounds become one colour, text another, links and buttons a third. Anything that relied on a background colour — a selected row tint, a red error cell, a coloured status badge, a focus ring drawn with box-shadow — can disappear. Users of contrast themes are a significant group among people with low vision and some with photosensitivity or migraines. Playwright can emulate forced colours, which makes these failures testable in CI.

This guide belongs to visual regression & theme testing.

Spec reference

Permalink to "Spec reference"
  • CSS forced-colors media feature — active when the user agent enforces a limited palette. System colour keywords (Canvas, CanvasText, Highlight, HighlightText, ButtonText, LinkText, GrayText) give access to the user’s palette.
  • forced-color-adjust — none opts an element out of forced colours (use sparingly, e.g. for colour swatches).
  • SC 1.4.11 Non-text Contrast (AA) and SC 2.4.7 Focus Visible (AA) — focus indicators and UI states must remain perceivable.
  • SC 1.4.1 Use of Color (A) — state conveyed only by background colour vanishes in forced colours, which is a useful test of colour independence.
What forced colours removes and what survives Comparison: background colours, box-shadow focus rings and colour-only status vanish; borders, outlines, text and system-colour styles survive. What forced colours removes and what survives✗ DisappearsRow selection tintbox-shadow focus ringRed background on error cellsColoured status badge fillsChart series colours (often)✓ SurvivesSelection drawn with outline or borderoutline focus ringText "Error" and an icon with a nameBadge text and borderPatterns, dashes and labels

When to use — and when not to

Permalink to "When to use — and when not to"

Capture forced-colours screenshots for components with rich state: grids (focus, selection, editing, errors), charts, badges, toggles and custom checkboxes. Add them to visual regression suites that already exist.

Do not treat screenshots as the only check; pair them with programmatic assertions (is an outline present on the focused cell?) so failures are explicit. Do not “fix” forced colours by setting forced-color-adjust: none broadly — that overrides the user’s chosen palette.

The misapplication: a single forced-colours screenshot of a page in its default state. The failures live in states — focus, selection, error — so capture those.

States to capture in forced colours Matrix of components — data grid, chart, badges, custom checkbox, toggle button — against default, focused, selected or checked, and error states. States to capture in forced coloursDefaultFocusedSelected or checkedErrorData gridCaptureCaptureCaptureCaptureChartCaptureCaptureHidden seriesN/ABadgesCaptureN/AN/ACaptureCustom checkboxCaptureCaptureCaptureN/AToggle buttonCaptureCaptureCaptureN/A
Failures hide in states, not default renderings.

Annotated code example

Permalink to "Annotated code example"
// forced-colors.spec.js
import { test, expect } from '@playwright/test';

test.describe('forced colors', () => {
  test.use({ colorScheme: 'dark' });                              // also run with 'light'
  test.beforeEach(async ({ page }) => { await page.emulateMedia({ forcedColors: 'active' }); });

  test('grid states', async ({ page }) => {
    await page.goto('/storybook/iframe.html?id=grid--with-selection');
    const grid = page.getByRole('grid', { name: 'Invoices' });
    await expect(grid).toHaveScreenshot('grid-default-fc.png');

    await page.keyboard.press('Tab');                              // focus first cell
    await expect(grid).toHaveScreenshot('grid-focused-fc.png');

    // Programmatic check: focused cell has a visible outline (SC 2.4.7)
    const outline = await page.evaluate(() => getComputedStyle(document.activeElement).outlineStyle);
    expect(outline).not.toBe('none');

    await page.keyboard.press('Space');                            // select row
    await expect(grid).toHaveScreenshot('grid-selected-fc.png');
  });
});
/* Styles the screenshots should confirm */
.grid [aria-selected="true"] { outline: 2px solid transparent; }   /* becomes visible in forced colours */
@media (forced-colors: active) {
  .grid [aria-selected="true"] { outline-color: Highlight; background: Highlight; color: HighlightText; }
  .grid :focus-visible { outline: 3px solid CanvasText; }
  .badge { border: 1px solid CanvasText; }
}

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"

Forced colours change visuals, not semantics; screen readers are unaffected. The users are mostly sighted, often keyboard users with low vision.

Check in forced colours Pass condition Typical failure
Focused grid cell Outline visible in system colour Focus via box-shadow — invisible
Selected row Highlight background or outline Tint removed — no selection visible
Error cell Text and icon visible Red fill removed — no error visible
Chart series Distinguishable by pattern or label All series one colour
Custom checkbox Box and tick visible Background-image tick removed
Adding forced-colors coverage to a suite Flow: pick components with state; write state-driving steps; capture screenshots with forced colours emulated; add programmatic assertions; review baselines once with a contrast theme user if possible. Adding forced-colors coverage to a suiteChoose componentsgrids, charts, badges, toggleswhere state livesDrive statesfocus, select, edit, errorkeyboard stepsCaptureemulateMedia forcedColors activelight and dark system themesAssertoutline present; text presentexplicit failuresReview baselinesideally with a contrast theme userbefore locking them in

Integration context

Permalink to "Integration context"

Manual forced-colours audits are described in Windows High Contrast audits for data UIs; this guide automates the repeatable part. Charts in forced colours are covered in forced colors mode for SVG charts. Screenshot automation more broadly is in automating focus indicator screenshots with Playwright.

Gotchas

Permalink to "Gotchas"

Emulation is Chromium-only. Playwright’s forcedColors emulation works in Chromium; the real feature is Windows-specific, and Firefox implements its own variant. Use emulation for regression tests and verify releases on Windows manually.

Baseline churn. Fonts and anti-aliasing differ between machines; run screenshots in a pinned container image.

SVG fills. SVG fill values are forced too in many cases; charts that rely on fill colours alone become monochrome. Patterns and labels survive.

Common mistakes

Permalink to "Common mistakes"
  • Capturing only default states.
  • Using forced-color-adjust: none on whole components.
  • Focus indicators drawn with box-shadow.
  • No programmatic assertions alongside screenshots.

Framework notes

Permalink to "Framework notes"

Storybook stories are ideal targets: one story per state, captured with forced colours emulated via the test runner. Chromatic supports forced colours through its forcedColors parameter in recent versions. In Cypress, use the Chrome DevTools Protocol (Emulation.setEmulatedMedia with forced-colors: active) via cy.task or a plugin.

// Storybook parameter (Chromatic) for a forced-colors snapshot
export const Selected = { parameters: { chromatic: { forcedColors: 'active' } } };

Worked example

Permalink to "Worked example"

A data grid component used background tints for selection and box-shadow for focus. In forced colours, users of Windows contrast themes could not see either. After adding forced-colors stories for focus, selection, editing and error states, the team moved focus to outline, added a forced-colors rule mapping selection to Highlight, and gave error cells an icon with text. The new screenshots became the baselines; two later regressions — a refactor back to box-shadow and a new badge without a border — were caught in pull requests.

Testing checklist

Permalink to "Testing checklist"

Design system notes

Permalink to "Design system notes"

Add a forced-colors section to each component’s stylesheet and a forced-colors story set to each component’s documentation. Make “renders correctly in forced colours” part of the component definition of done.

FAQ

Permalink to "FAQ"
Is forced colours the same as dark mode?

No. Dark mode is your palette; forced colours is the user’s palette, applied by the browser.

Can I test forced colours on macOS or Linux?

Emulation in Chromium works on any OS. The real Windows feature should still be checked before releases.

Should I opt components out with forced-color-adjust: none?

Only small parts that must show real colours, like colour swatches in a palette picker. Never whole components.

Which system colours should I use?

CanvasText for text and outlines, Canvas for backgrounds, Highlight and HighlightText for selection, LinkText for links, GrayText for disabled.

Does forced colours affect SVG charts?

Yes; fills and strokes may be forced. Use patterns, dashes and labels so series remain distinguishable.

How many screenshots are enough?

One per meaningful state for each stateful component. That is usually a few dozen for a data UI library.

Should icons have forced-colors styles?

SVG icons drawn with currentColor adapt automatically. Icons with hard-coded fills may vanish or become invisible; switch them to currentColor or add forced-colors rules.

Permalink to "Related"

← Back to Visual Regression & Theme Testing