Forced Colors & High Contrast Mode for Token Systems
Part of Advanced Theming & Dark Mode Implementation. This page covers the specific sub-problem of making a token-driven UI survive Windows High Contrast Mode and other forced-colors environments, where the operating system overrides every author-defined color with a small palette of system color keywords — and explains what that means for the --color-* custom property cascade you have carefully built.
Problem Framing
A token system built on CSS custom properties gives you one contract: the browser resolves --ds-color-action-primary at paint time and the component receives the correct value. Forced-colors mode breaks that contract unilaterally. Windows High Contrast Mode — surfaced to CSS via forced-colors: active — instructs the browser to ignore author-specified color, background-color, border-color, outline-color, box-shadow, and text-decoration-color declarations and replace them with values from a restricted system palette. Custom properties themselves are not touched: --ds-color-action-primary still holds #2563eb. But the property color: var(--ds-color-action-primary) on a real element gets overridden before paint. Your tokens survive in the cascade; their computed effect on the screen does not.
The design gap this creates is subtle but serious. Interactive states — hover, focus, disabled, error — that you communicate via color alone become invisible. A focus ring built with box-shadow disappears entirely because box-shadow is zeroed in forced-colors mode. A disabled button styled only with a lighter token value looks identical to an active button. For teams that treat forced-colors as an afterthought, this is often discovered in accessibility audits or — worse — bug reports from actual Windows users running High Contrast themes.
The practical consequence: any design system that claims WCAG 2.2 AA conformance must be tested under forced-colors conditions. The forced-colors media query and the CSS system color keywords give you a precise, standards-defined surface to address this.
forced-colors: active. The custom property retains its value throughout the cascade, but the browser replaces any author color property on real elements with a system color keyword before paint.The forced-colors Media Query
@media (forced-colors: active) {
/* rules here apply when the OS enforces a color palette */
}
forced-colors: active matches Windows High Contrast Mode (both the legacy ms-high-contrast modes and the modern Forced Colors implementation in Chromium and Firefox), as well as any platform that enforces a restricted palette. The none value matches all normal rendering contexts and is rarely used explicitly — most author code omits the media query and targets active only when corrections are needed.
The media query does not tell you which high-contrast theme is active — there is no way to know whether the user is running “High Contrast Black,” “High Contrast White,” or a custom palette. You work with abstract system color keywords, not specific hex values. That is intentional: the user chose their palette for accessibility reasons and the browser adapts your UI to it.
What the browser overrides automatically
When forced-colors: active, the browser zeroes or overrides the following CSS properties on all elements unless you opt out via forced-color-adjust: none:
| Property | Forced behavior |
|---|---|
color |
Replaced with CanvasText (or the appropriate system color for the element’s role) |
background-color |
Replaced with Canvas |
border-color |
Replaced with ButtonBorder or CanvasText depending on context |
outline-color |
Replaced with Highlight or ButtonText |
box-shadow |
Set to none |
text-shadow |
Set to none |
text-decoration-color |
Replaced with LinkText or CanvasText |
fill / stroke (SVG) |
Replaced with CanvasText |
scrollbar-color |
Replaced with system scrollbar colors |
Background images on non-<img> elements are removed entirely. This is particularly important for icon-as-background-image patterns.
CSS System Color Keywords
The browser exposes the active forced-colors palette through a fixed set of CSS system color keywords. These resolve to whatever the user’s OS theme specifies for that semantic role. They are valid in any CSS color position — including inside custom property values.
| Keyword | Semantic role |
|---|---|
Canvas |
Default page/application background |
CanvasText |
Text on Canvas background |
LinkText |
Unvisited hyperlink color |
VisitedText |
Visited hyperlink color |
ActiveText |
Active (pressed) link color |
ButtonFace |
Button background |
ButtonText |
Button foreground text |
ButtonBorder |
Button border |
Field |
Text input background |
FieldText |
Text input foreground |
Highlight |
Selected content background |
HighlightText |
Selected content foreground |
Mark |
Highlighted text background (HTML mark) |
MarkText |
Highlighted text foreground |
GrayText |
Disabled/unavailable text |
These keywords are valid in normal rendering too — they resolve to the current OS theme values, which may not match your brand. Use them exclusively inside @media (forced-colors: active) blocks unless you have a deliberate reason to surface OS colors in standard rendering.
forced-color-adjust
The forced-color-adjust property controls whether the browser applies forced-colors overrides to a specific element’s subtree:
.element {
forced-color-adjust: auto; /* default: browser overrides apply */
forced-color-adjust: none; /* opt out: author colors are preserved */
}
forced-color-adjust: none is the escape hatch, but it is frequently misused. When you opt out, the element’s subtree keeps your author colors — which may be low-contrast against the user’s chosen background. You have now made the element less accessible in a mode the user activated specifically for accessibility. Use it only when the semantic meaning of the element depends on the precise color values in a way that the forced-colors palette cannot represent.
Legitimate uses of forced-color-adjust: none
- Color pickers and palette swatches — the element is the color; removing it destroys its purpose.
- Data visualization with categorical color encoding — a bar chart where each series is a distinct color; use
nonehere but also add a pattern fill or direct label fallback. - Gamut-critical photography thumbnails — image aesthetics rather than UI meaning.
Illegitimate uses (anti-pattern)
- Focus indicators — you must preserve or re-implement these; opt-out does not restore their visibility.
- Status badges — use system color keywords instead.
- Interactive state communication (hover, active, disabled) — map to the correct keyword pair.
Mapping Tokens to System Colors in Forced-Colors Mode
Your design tokens do not disappear in forced-colors mode — they still exist as custom property values. The browser simply stops using them as the source of truth for rendered color on elements. The architectural move is to re-define your semantic tokens inside the forced-colors media query to resolve to system color keywords. The component CSS stays untouched.
/* Base token definitions (design-token layer) */
@layer design-tokens {
:root {
--ds-color-surface-default: #ffffff;
--ds-color-text-primary: #0f172a;
--ds-color-text-disabled: #94a3b8;
--ds-color-action-primary: #2563eb;
--ds-color-action-primary-fg: #ffffff;
--ds-color-border-focus: #2563eb;
--ds-color-border-interactive: #cbd5e1;
--ds-color-state-error: #ef4444;
--ds-color-highlight: #dbeafe;
}
}
/* Forced-colors override layer — re-maps tokens to system keywords */
/* @depends: design-tokens */
@layer forced-colors-overrides {
@media (forced-colors: active) {
:root {
--ds-color-surface-default: Canvas;
--ds-color-text-primary: CanvasText;
--ds-color-text-disabled: GrayText;
--ds-color-action-primary: ButtonText;
--ds-color-action-primary-fg: ButtonFace;
--ds-color-border-focus: Highlight;
--ds-color-border-interactive: ButtonBorder;
--ds-color-state-error: LinkText; /* LinkText is typically red in HC themes */
--ds-color-highlight: Highlight;
}
}
}
With this in place, a button that reads:
.btn-primary {
background-color: var(--ds-color-action-primary);
color: var(--ds-color-action-primary-fg);
border: 1px solid var(--ds-color-border-interactive);
}
.btn-primary:focus-visible {
outline: 2px solid var(--ds-color-border-focus);
outline-offset: 2px;
}
…now correctly renders with ButtonText background, ButtonFace text, and a Highlight focus ring in forced-colors mode — all semantically correct to the OS palette without touching the component rule set. The focus ring is preserved because outline (unlike box-shadow) survives forced-colors overrides, and it resolves to a system keyword that the OS guarantees to be visible.
This token-remapping strategy is the key architectural insight: keep component CSS clean and semantically token-driven; confine all platform-adaptation logic to the token layer.
Preserving State Communication via System Colors
Color-only state communication is WCAG 1.4.1 failure. In forced-colors mode, the constraints tighten further because the user’s palette may have very few distinct colors. You have at most the system keywords listed above. State must be preserved through the choice of the correct keyword, not through tinting.
| UI state | Recommended system keyword pair |
|---|---|
| Default interactive | ButtonFace / ButtonText |
| Focused | Highlight outline or ButtonText with Highlight outline |
| Hovered | Highlight / HighlightText for selected-like emphasis |
| Active/pressed | ActiveText on links; Highlight / HighlightText for buttons |
| Disabled | GrayText for text; ButtonBorder for borders; ButtonFace bg |
| Error | LinkText (conventionally red in most HC themes) |
| Success | Canvas / CanvasText with explicit border |
| Selected | Highlight / HighlightText |
| Visited link | VisitedText |
If you are communicating state purely through background hue (green success banner, red error banner), those are identical surfaces in forced-colors mode. Add a border via ButtonBorder, an icon with forced-color-adjust: none if the icon is a meaningful semantic signal, or a text label.
Architectural Trade-offs
-
Token remapping preserves component isolation vs. token layer coupling. Remapping tokens inside
forced-colorskeeps components ignorant of platform context. The cost is that every semantic token must have an explicit system-color counterpart; token schemas that were not designed with forced-colors in mind require a non-trivial audit pass. -
forced-color-adjust: nonegives precise control vs. shifts responsibility to the author. Opting out of browser overrides means you retain your colors exactly — but you must manually ensure contrast is acceptable against the user’s background, which you cannot know at author time. Use with extreme caution. -
Outline-based focus rings survive by default vs. box-shadow focus rings are erased. Teams that switched from
outlinetobox-shadowfor visual design reasons (rounded focus rings, etc.) must re-add anoutlinefallback. Theforced-colorsmedia query is the right place to do it rather than polluting normal-rendering styles. -
System keyword assignment is semantic vs. color-result assumptions are fragile.
LinkTextis often red in Windows HC themes, making it useful for error states. But on a user-configured custom theme it could be any color. Map keywords by semantic role, not by assumed color value. -
SVG icons require explicit handling vs. automatic override may flatten them. The browser zeroes SVG
fillandstroketoCanvasText. Monochrome icons become CanvasText-colored automatically, which is usually correct. Icons using multiple fills for meaning — think a bicolor status indicator — lose that distinction. Add explicit fills in the forced-colors block or restructure to use a single semantic color.
How This Interacts with the prefers-color-scheme Cascade
A user running Windows High Contrast Mode will often also have prefers-color-scheme: dark active, because the most common HC themes are dark. Both media queries fire. Your layer order must handle this without conflicts. The recommended cascade order is:
@layer design-tokens,
theme-light,
theme-dark,
forced-colors-overrides,
components;
The forced-colors-overrides layer sits above theme-dark so that system keyword mappings take precedence over dark-theme token values. Components still sit above both, but component rules only name tokens — they never hard-code colors — so the cascade resolves cleanly. For a detailed look at the prefers-color-scheme side of this stack, see Prefers-Color-Scheme Integration.
Numbered Workflow
-
Audit your semantic token inventory. List every
--ds-color-*token used in component CSS. Identify which ones carry interactive state meaning (focus, hover, disabled, error, selected). These need system-color counterparts first. -
Add a
forced-colors-overrideslayer to your token output. In Style Dictionary or your token compiler, add a platform-specific transform that generates@media (forced-colors: active) { :root { … } }output with system-color values for every semantic token. Start with the interactive-state tokens identified in step 1; surface, text, and border tokens follow. -
Audit focus indicators. Search your CSS for
box-shadowrules that implement focus rings. Replace each with anoutline-based equivalent, or add the outline back inside@media (forced-colors: active). Thebox-shadowapproach can coexist in normal rendering; you only need to restoreoutlinefor forced-colors. -
Audit icon handling. Identify decorative vs. semantic SVG icons. Decorative icons need no change. Semantic icons that use color to convey meaning need either a text label, a
titleelement, or a restrictedforced-color-adjust: nonescope with a guaranteed-visible color token. -
Audit color-only state communication. Any UI state communicated solely by background or text hue must gain an additional non-color signal: a border, a shape change, a text label, or a pattern. Map the color-based token to the appropriate system keyword.
-
Test in Windows High Contrast Mode and in browser DevTools emulation. Real Windows testing catches edge cases that emulation misses (especially around scrollbar and form control rendering). Both environments are necessary; see the CI snippet below.
-
Wire up CI emulation. Add a Playwright job (see the snippet below) that runs your visual regression or component smoke tests with forced-colors emulation enabled. Gate the build on it.
-
Document the token-to-system-keyword mapping in your design system’s token schema. A JSON annotation like
"forcedColorsKeyword": "ButtonText"on each semantic token makes the mapping auditable and keeps it in sync when tokens are renamed.
Validation & Quality Gates
CI Playwright Snippet
// tests/forced-colors.spec.js
import { test, expect } from '@playwright/test';
// Emulate forced-colors in Chromium
const forcedColorsContext = {
colorScheme: 'dark',
forcedColors: 'active', // Playwright 1.41+
};
test.describe('forced-colors smoke tests', () => {
test.use({ colorScheme: 'dark', forcedColors: 'active' });
test('primary button: focus ring visible', async ({ page }) => {
await page.goto('/components/button');
const btn = page.locator('[data-testid="btn-primary"]');
await btn.focus();
// outline must be non-zero in forced-colors mode
const outline = await btn.evaluate(
el => getComputedStyle(el).outlineWidth
);
expect(parseFloat(outline)).toBeGreaterThan(0);
});
test('disabled button: text uses GrayText', async ({ page }) => {
await page.goto('/components/button');
const disabledBtn = page.locator('[data-testid="btn-disabled"]');
// In forced-colors mode, color should resolve to GrayText system color
// We verify the element is marked disabled and the computed color token exists
await expect(disabledBtn).toBeDisabled();
const colorValue = await disabledBtn.evaluate(
el => getComputedStyle(el).getPropertyValue('--ds-color-text-disabled').trim()
);
expect(colorValue).toBe('GrayText');
});
test('error state: visible without color reliance', async ({ page }) => {
await page.goto('/components/form');
await page.fill('[data-testid="email-input"]', 'invalid');
await page.keyboard.press('Tab');
const errorMsg = page.locator('[data-testid="email-error"]');
await expect(errorMsg).toBeVisible();
});
});
# .github/workflows/forced-colors.yml
name: Forced Colors CI
on: [push, pull_request]
jobs:
forced-colors-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test tests/forced-colors.spec.js
env:
CI: true
| Tool | Purpose | Integration Point |
|---|---|---|
Playwright (forcedColors: 'active') |
Chromium forced-colors emulation in CI | test.use() per suite or globally in playwright.config.js |
| Windows High Contrast Mode | Real platform validation | Manual test on Windows VM or BrowserStack |
| Firefox DevTools Simulation | Cross-engine verification | DevTools → Accessibility → Simulate Forced Colors |
axe-core / axe-playwright |
Automated WCAG check alongside visual | Post-navigation checkA11y() call |
| Chromatic / Percy | Visual regression snapshot | Run both normal and forced-colors snapshots |
Cross-Cluster Dependency Mapping
| Concern | Integration Point | Validation Strategy |
|---|---|---|
| Advanced Theming & Dark Mode parent | Layer order: forced-colors overrides must sit above theme-dark |
CI Playwright + manual Windows test |
| Prefers-Color-Scheme Integration | theme-dark and forced-colors-overrides layers coexist |
Test with prefers-color-scheme: dark AND forced-colors: active simultaneously |
| Semantic color token structure | Every semantic token needs a system-keyword counterpart | Token schema JSON annotation review |
| Supporting Windows High Contrast with forced-colors | Step-by-step implementation of the mapping workflow | PR checklist for new token additions |
/* @depends: design-tokens, theme-dark */
/* Forced-colors overrides must be declared after all theme layers */
@layer forced-colors-overrides {
@media (forced-colors: active) {
:root {
/* Remap interactive tokens to OS palette keywords */
--ds-color-action-primary: ButtonText;
--ds-color-border-focus: Highlight;
--ds-color-text-disabled: GrayText;
}
}
}
Production Code Reference
Complete forced-colors token remapping block
/*
* forced-colors-overrides.css
* Generated by token compiler; do not edit manually.
* All --ds-color-* custom properties are remapped to CSS system color keywords
* so components continue to use var(--ds-color-*) without change.
*/
@layer forced-colors-overrides {
@media (forced-colors: active) {
:root {
/* Surfaces */
--ds-color-surface-default: Canvas;
--ds-color-surface-elevated: Canvas;
--ds-color-surface-overlay: Canvas;
/* Text */
--ds-color-text-primary: CanvasText;
--ds-color-text-secondary: CanvasText;
--ds-color-text-placeholder: GrayText;
--ds-color-text-disabled: GrayText;
--ds-color-text-on-action: ButtonFace;
--ds-color-text-link: LinkText;
--ds-color-text-link-visited: VisitedText;
/* Borders */
--ds-color-border-default: ButtonBorder;
--ds-color-border-interactive: ButtonBorder;
--ds-color-border-focus: Highlight;
--ds-color-border-error: LinkText;
/* Actions */
--ds-color-action-primary: ButtonText;
--ds-color-action-primary-bg: ButtonFace;
--ds-color-action-primary-hover: Highlight;
--ds-color-action-primary-active: ActiveText;
--ds-color-action-danger: LinkText;
/* States */
--ds-color-state-error: LinkText;
--ds-color-state-success: CanvasText;
--ds-color-state-warning: CanvasText;
--ds-color-state-info: CanvasText;
/* Selection */
--ds-color-selection-bg: Highlight;
--ds-color-selection-text: HighlightText;
}
}
}
Why this works: every component rule references a --ds-color-* custom property. The browser resolves the property to a system keyword at computed-value time. The OS substitutes that keyword with the actual palette color. No component CSS changes. The entire adaptation lives in one generated file.
Focus ring restoration for box-shadow implementations
/*
* If your normal-rendering focus style uses box-shadow (common for
* rounded inset rings), restore an outline fallback for forced-colors.
*/
.btn:focus-visible {
/* Normal rendering: rounded box-shadow ring */
box-shadow: 0 0 0 3px var(--ds-color-border-focus);
outline: none;
}
@media (forced-colors: active) {
.btn:focus-visible {
/* box-shadow is zeroed by the browser; add outline explicitly */
box-shadow: none;
outline: 3px solid var(--ds-color-border-focus);
outline-offset: 2px;
/* --ds-color-border-focus resolves to Highlight in forced-colors mode */
}
}
Why this works: outline is not overridden to zero by forced-colors — unlike box-shadow. The var(--ds-color-border-focus) token resolves to Highlight in forced-colors mode per the override layer, so the ring uses the OS-guaranteed high-visibility selection color.
Diagnostic Matrix
| Diagnostic Step | Execution Detail |
|---|---|
| Confirm forced-colors is active | In Chromium DevTools: Rendering panel → Force CSS media features → forced-colors: active |
| Check custom property values in HC mode | DevTools Computed panel: inspect --ds-color-* values; they should show system keywords |
| Verify focus ring visibility | Tab through all interactive elements; every focused element must show a visible ring |
| Test disabled state contrast | Visually compare disabled vs. active controls; they must be distinguishable without color alone |
| Check SVG icon rendering | Inspect SVG elements; decorative icons should be CanvasText-colored; semantic ones should be visible |
| Root Cause | Symptom | Resolution |
|---|---|---|
box-shadow used for focus ring |
Focus ring invisible in HC mode | Add outline inside @media (forced-colors: active) block |
| Missing forced-colors token mapping | Interactive elements use raw #hex values, look broken |
Add system keyword assignments to forced-colors-overrides layer |
forced-color-adjust: none on a state-bearing element |
Disabled button looks identical to active in HC | Remove opt-out or manually assign GrayText for disabled text |
| Background image icons | Icon disappears in HC mode | Switch to inline SVG or <img>, both of which survive forced-colors |
| Color-only error/success communication | Error and success states indistinguishable | Add border, icon, or text label; map token to LinkText for errors |
Frequently Asked Questions
Does forced-colors: active mean the user is on Windows?
Not exclusively. Windows High Contrast Mode is the most common trigger, but the forced-colors: active condition can also fire on some Linux accessibility tools and on specialized hardware. The media query is platform-agnostic by design. You cannot detect which OS or specific theme is active; you only know that a restricted system palette is being enforced. Write defensive code — use system color keywords rather than making platform assumptions.
Should I map every single design token to a system keyword?
Map every semantic token — tokens that carry UI meaning (text, surface, border, interactive states). Primitive/reference tokens (the raw --color-blue-600: #2563eb tier) do not need mapping because component CSS should never use primitives directly. If you are disciplined about the three-tier token hierarchy described in how to structure semantic color tokens for accessibility, the mapping target list is well-bounded and exhaustive.
Will my dark mode theme interfere with forced-colors mode?
Only if the forced-colors-overrides layer is ordered before theme-dark in the cascade, which would let dark-mode token values override your system keyword assignments. Ensure forced-colors-overrides is declared after all theme layers (see the layer order above). Because the forced-colors media query adds cascade weight, in practice browsers also apply the OS overrides on top of any author styles — but relying on that implicit layering rather than explicit @layer order is fragile. Be explicit.
Related
- Advanced Theming & Dark Mode Implementation — parent overview covering the full theming architecture these techniques belong to.
- Supporting Windows High Contrast with forced-colors — step-by-step implementation of the token mapping and focus ring workflow with runnable code.
- Prefers-Color-Scheme Integration — the sibling cluster covering automatic dark mode switching; its cascade layer order must be coordinated with forced-colors overrides.
- How to Structure Semantic Color Tokens for Accessibility — the foundational page on building a semantic token tier that maps cleanly to system color keywords in forced-colors mode.