Supporting Windows High Contrast with forced-colors

Part of Forced Colors & High Contrast Mode. This page walks through making a component set render correctly under @media (forced-colors: active) — covering token remapping, invisible-border fixes, SVG icon handling, and distinguishable interaction states.

Button token mapping in normal mode vs forced-colors mode Two side-by-side button diagrams showing how CSS custom property color tokens collapse to system color keywords when forced-colors is active. Normal Mode --ds-color-action -primary: #2563eb --ds-color-text -on-action: #fff --ds-color-border -focus: #1d4ed8 --ds-color-action -hover: #1e40af Save Changes forced-colors: active Forced-Colors Mode background: ButtonFace (replaces action-primary) color: ButtonText (replaces text-on-action) outline: 2px solid ButtonText (replaces border-focus) background: Highlight (hover → Highlight keyword) Save Changes
A button's design token values (left) collapse to system color keywords (right) when forced-colors: active is detected, ensuring the OS controls all color decisions.

Prerequisites

  • Your component library uses CSS custom properties as a token layer (e.g., --ds-color-action-primary) rather than hardcoded hex values. The color palette architecture pages cover how to structure that layer.
  • You target Windows 11 or Windows 10 with High Contrast Mode enabled, or Chrome/Edge’s DevTools “Forced Colors” emulation.
  • Browser support target includes Chromium 89+, Firefox 89+, and Edge 89+. Safari does not implement forced-colors at time of writing.
  • Your build pipeline produces standard CSS (PostCSS, Sass, or vanilla custom properties). CSS-in-JS runtimes that inline every style may need additional surface-area work.
  • You have a way to run visual checks in forced-colors mode — either the DevTools Rendering panel, Playwright with forcedColors: 'active', or a Windows VM.

Step-by-step Implementation

Step 1 — Establish the forced-colors media query wrapper

Create a dedicated CSS block that wraps all forced-colors overrides. Do not scatter these declarations across component files without structure; a single location makes auditing straightforward.

/* tokens/forced-colors.css */

@media (forced-colors: active) {
  :root {
    /*
     * Slot your semantic tokens onto CSS system color keywords.
     * The browser ignores these values for actual rendering — the OS
     * overrides them — but mapping them here keeps the token API intact
     * for any CSS that reads var(--ds-color-*) inside this media query.
     */
    --ds-color-action-primary:    ButtonFace;
    --ds-color-text-on-action:    ButtonText;
    --ds-color-surface-primary:   Canvas;
    --ds-color-text-primary:      CanvasText;
    --ds-color-border-default:    ButtonBorder;
    --ds-color-border-focus:      Highlight;
    --ds-color-action-hover:      Highlight;
    --ds-color-text-on-hover:     HighlightText;
    --ds-color-link:              LinkText;
    --ds-color-link-visited:      VisitedText;
  }
}

Why this works. In forced-colors mode, the browser replaces any author-specified color with one of the 10 CSS system color keywords. By mapping your semantic tokens to those same keywords inside the media query, components that already read var(--ds-color-action-primary) continue to work without per-component overrides. The OS sees the system keyword and can apply its own palette on top.


Step 2 — Fix invisible borders and outlines

High contrast users depend on visible borders to understand component boundaries. Borders defined with a color token that evaluates to transparent or a near-white value will vanish entirely. You must force a visible border.

@media (forced-colors: active) {
  /* Buttons */
  .btn {
    border: 2px solid ButtonBorder;
  }

  /* Input fields */
  .input,
  .select,
  .textarea {
    border: 2px solid ButtonBorder;
    outline: none; /* avoid double-outline on focus; Step 3 handles focus */
  }

  /* Cards and panels that rely on box-shadow for depth */
  .card,
  .panel {
    border: 1px solid ButtonBorder;
    box-shadow: none; /* shadows are suppressed by the OS anyway */
  }
}

Why this works. ButtonBorder is a system color keyword guaranteed to be visible against ButtonFace in any high-contrast palette the user has chosen — whether black-on-white, white-on-black, or a custom high-saturation theme. Using border: 2px solid ButtonBorder gives you a consistent, meaningful affordance without guessing at the user’s chosen colors.


Step 3 — Restore focus rings

The browser can strip custom outline styles in forced-colors mode when they rely on color tokens that no longer resolve correctly. A visible focus indicator is a WCAG 2.1 success criterion (2.4.7) and critical for keyboard navigation.

@media (forced-colors: active) {
  :focus-visible {
    outline: 3px solid Highlight;
    outline-offset: 2px;
    /* forced-color-adjust: none is NOT used here — we want the OS to own the color */
  }

  /* Remove box-shadow-based focus rings; they are invisible in forced-colors mode */
  :focus-visible {
    box-shadow: none;
  }

  /* Ensure custom focus components do not rely on background-color alone */
  .focus-ring-custom {
    outline: 3px solid Highlight;
    outline-offset: 3px;
    border-color: Highlight;
  }
}

Why this works. Highlight is the system color for selected text backgrounds and active focus indicators — exactly the semantic meaning you want. The OS renders it in a color that contrasts against both Canvas and ButtonFace, so it is always legible regardless of which high-contrast theme is active.


Step 4 — Handle SVG icons with forced-color-adjust

Inline SVGs and <img> icons are the most frequent failure mode in high contrast audits. The browser cannot recolor rasterized images, and SVGs whose fill and stroke are set as presentation attributes (not via CSS) are also not remapped.

@media (forced-colors: active) {
  /* Icons that must inherit document text color */
  .icon {
    forced-color-adjust: auto;
    /* 'auto' allows the browser to remap CSS-driven colors */
    fill: currentColor;
    stroke: currentColor;
  }

  /* Icons that must stay on a specific background (e.g., status dots) */
  .icon--status {
    forced-color-adjust: none;
    /*
     * 'none' opts out of OS remapping. Use sparingly.
     * YOU are now responsible for visible contrast.
     * Provide explicit forced-colors-safe colors below.
     */
    fill: CanvasText;
    stroke: Canvas;
  }

  /* Hide purely decorative SVGs so they don't create noise */
  .icon--decorative {
    visibility: hidden;
  }
}

For SVGs defined with presentation attributes (e.g., <path fill="#2563eb">), add a CSS rule that overrides them:

@media (forced-colors: active) {
  svg path,
  svg circle,
  svg rect {
    fill: currentColor;
    stroke: currentColor;
  }
}

Why this works. forced-color-adjust: auto (the default) lets the browser remap CSS-authored colors to system keywords. forced-color-adjust: none is an escape hatch that preserves your exact authored values — useful for icons that encode meaning through specific colors (traffic-light status, chart fills), but it places the accessibility burden on you. The CSS presentation-attribute override is needed because presentation attributes have higher specificity than inherited values but lower specificity than explicit CSS rules; the @media block wins.


Step 5 — Make hover and selected states distinguishable

In forced-colors mode you cannot rely on background-color changes alone to indicate hover or selection — the OS may map all backgrounds to ButtonFace. You need a structural change (border, outline, or text-decoration) to convey state.

@media (forced-colors: active) {
  /* Hover: use Highlight keyword for background, HighlightText for label */
  .btn:hover,
  .menu-item:hover {
    background-color: Highlight;
    color: HighlightText;
    forced-color-adjust: none; /* own these two declarations */
  }

  /* Selected / active list item */
  .menu-item[aria-selected="true"],
  .tab[aria-selected="true"] {
    background-color: Highlight;
    color: HighlightText;
    forced-color-adjust: none;
  }

  /* Disabled: must look distinct from both normal and hover */
  .btn:disabled,
  [aria-disabled="true"] {
    border-color: GrayText;
    color: GrayText;
    forced-color-adjust: none;
  }
}

Why this works. The Highlight / HighlightText pair is defined by the OS as a contrast-safe duo — the text color is always legible against the background color. GrayText is the system keyword for visually de-emphasized content; using it for disabled states signals unavailability in every high-contrast palette without you needing to pick a specific gray.


Step 6 — Preserve meaningful images with forced-color-adjust: none

Some images carry semantic meaning that the OS would destroy by converting them to grayscale or removing them (browser behavior varies). Use forced-color-adjust: none on the container and a visible border to signal the image’s presence.

@media (forced-colors: active) {
  .avatar,
  .product-thumbnail,
  .chart-image {
    forced-color-adjust: none;
    outline: 2px solid ButtonBorder;
    outline-offset: 1px;
  }
}

Why this works. Opting out of forced-color adjustment on an image container preserves the author’s colors for everything within it, keeping photographs and charts legible. The added outline provides a bounding box that the high-contrast user can see even if the image itself blends into the background at its edges.


Step 7 — Confirm color-scheme meta is set

Windows High Contrast mode interacts with the color-scheme property. If your root declares only color-scheme: dark, some browsers will not correctly apply the forced-colors layer on top.

:root {
  /* Accept both light and dark system schemes so the browser
     can overlay forced-colors correctly on either */
  color-scheme: light dark;
}
<!-- Also set at the document level -->
<meta name="color-scheme" content="light dark">

Why this works. Declaring color-scheme: light dark signals to the browser that your document accepts whatever color scheme the OS is using as a base. Forced-colors then layers on top of that baseline correctly. Omitting this or hardcoding a single value can cause the browser to pick the wrong system palette before forced-colors overrides land.


Verification

DevTools Rendering panel

  1. Open Chrome DevTools (F12) or Edge DevTools.
  2. Open the Rendering panel (More tools → Rendering).
  3. Set Emulate CSS media feature forced-colors to active.
  4. Walk through every interactive component: buttons, inputs, links, focus states, selected states, disabled states, and icon-only controls.
  5. Confirm every element has a visible boundary, visible text, and a visible focus indicator when tabbed to.

Playwright automated check

// tests/forced-colors.spec.ts
import { test, expect } from '@playwright/test';

test.use({ forcedColors: 'active' });

test('button has visible border in forced-colors mode', async ({ page }) => {
  await page.goto('/components/button');

  const btn = page.locator('.btn').first();
  await expect(btn).toBeVisible();

  // Confirm the outline/border is non-zero (structural check)
  const borderWidth = await btn.evaluate((el) =>
    getComputedStyle(el).borderWidth
  );
  expect(parseInt(borderWidth, 10)).toBeGreaterThan(0);
});

test('focus ring is visible on keyboard navigation', async ({ page }) => {
  await page.goto('/components/button');
  await page.keyboard.press('Tab');

  const focused = page.locator(':focus-visible');
  const outline = await focused.evaluate((el) =>
    getComputedStyle(el).outlineWidth
  );
  expect(parseInt(outline, 10)).toBeGreaterThan(0);
});

The forcedColors: 'active' option in Playwright instructs Chromium to apply the forced-colors media feature during the test run, giving you the same rendering that Windows High Contrast users see.


Troubleshooting

Symptom Likely Cause Fix
Button background and label are both invisible Token resolves to transparent or a very light color that maps identically to Canvas Explicitly set background-color: ButtonFace; color: ButtonText inside the @media (forced-colors: active) block for the .btn rule
Focus ring disappears after tabbing Component uses box-shadow for the focus style, and box shadows are suppressed in forced-colors Replace box-shadow-based focus with outline: 3px solid Highlight; outline-offset: 2px in the forced-colors block
SVG icon disappears entirely fill is set as a presentation attribute (<path fill="#hex">) and does not respond to CSS inheritance Add fill: currentColor and stroke: currentColor via CSS inside the media query; this overrides presentation attributes
Selected list item looks identical to unselected State was communicated only by background-color shift, which the OS flattens Apply background-color: Highlight; color: HighlightText; forced-color-adjust: none on [aria-selected="true"]
Image with text overlay becomes illegible forced-color-adjust: auto on the parent remaps the overlay color to a value that clashes with the image Add forced-color-adjust: none to the text overlay element specifically; add an outline to keep the image boundary visible

Migration Note

If your component library currently uses inline style attributes or CSS-in-JS runtimes that emit styles with very high specificity, your @media (forced-colors: active) rules may be silently losing the specificity battle. Audit with DevTools’ Styles panel; forced-colors overrides should appear in the cascade and show the winning declaration.

A phased migration looks like this:

  1. Audit phase: Run Playwright with forcedColors: 'active' against every component in your Storybook or component catalog. Record failures.
  2. Token-first fixes: Add the @media (forced-colors: active) :root { } block from Step 1 first. Re-run the audit to see how many components self-correct just from correct token mapping.
  3. Component-level overrides: For remaining failures, add the targeted component rules (Steps 2–6) in a forced-colors.css partial that is imported after all component stylesheets.
  4. Linting: Add a Stylelint rule (or a custom lint check) that warns when border-color, outline-color, or box-shadow use hardcoded hex values without a corresponding @media (forced-colors: active) counterpart. This prevents regressions as the component library grows.

When integrating with your prefers-color-scheme dark mode implementation, note that forced-colors takes precedence over your dark mode token set — the OS completely overrides your theme palette regardless of whether [data-theme="dark"] is present. The forced-colors media block should therefore be at the end of your stylesheet cascade, and its rules should not depend on the data-theme attribute.