You’ve probably seen this happen. A polished component library looks solid in your default theme, maybe even in dark mode, and then Windows High Contrast Mode turns it into a minefield of missing borders, vanished icons, and buttons that no longer read like buttons.
That usually isn’t a one-off bug. It’s a signal that the system is carrying too much of the accessibility burden for your UI.
In production apps, high contrast mode support works best when it’s treated as part of the component contract. Not as a late CSS patch. Not as a theme toggle bolted onto the settings page. If your team already uses design tokens, utility classes, and reusable primitives, you have the right foundation. You just need to model forced colors as a real environment your components must survive.
Table of Contents
- Why High Contrast Mode Is Not Just Another Theme
- Detecting User Preferences with CSS
- Styling for an Environment with Forced Colors
- Integrating HCM into Modern Design Systems and Theming
- A Practical Component Refactor for High Contrast Mode
- How to Test and Audit Your Application for HCM
- Frequently Asked Questions About High Contrast Mode
Why High Contrast Mode Is Not Just Another Theme
A team ships a polished dark mode, checks contrast ratios, and assumes the accessibility work is in good shape. Then a Windows user turns on High Contrast Mode and the interface starts dropping meaning. Primary buttons lose their visual hierarchy, selected states flatten out, and icon-only controls become hard to identify. That failure usually starts with one bad assumption. High contrast support gets treated like another branded theme.
High Contrast Mode has been a core operating system accessibility feature since Windows 95 in 1995, which means developers have had over 30 years of platform support pointing to the same conclusion: this is a long-standing accessibility requirement, not a product preference, according to Minnesota IT Services on Windows High Contrast Mode.

The browser is not in charge anymore
Dark mode still runs inside your design system. High contrast mode often does not.
In a forced-colors environment, the operating system and browser can replace authored colors with a constrained palette chosen for legibility. That changes the job. The goal is no longer to preserve brand expression. The goal is to preserve meaning, hierarchy, and interaction when authored color decisions lose authority.
That has direct consequences for component work:
- Semantic tokens need a fallback path:
primary-600anddanger-500are useful in normal rendering, but they cannot be the only signal a component depends on. - Visual effects stop carrying meaning: Shadows, soft backgrounds, gradients, and tinted borders are weak signals once colors are overridden.
- State needs structural support: Focus, selection, disabled, error, and pressed states must still read clearly through borders, outlines, labels, spacing, and content changes.
This is why I treat high contrast support as a component contract, not a theme variant. If a button only looks primary because it is blue, or an error only reads as an error because it is red, the component is under-specified.
Users bring their own contrast settings
There is another implementation trap. Teams test one black-and-white setup, then wire that assumption into the UI.
Users do not all choose the same palette, and they should not have to. High contrast settings are often personalized at the operating system level. Some people need darker backgrounds. Others need different foreground and highlight combinations to reduce glare or improve readability. A component that survives only one contrast combination is still brittle.
That is why the right comparison is operational, not visual:
| Mode | Who chooses colors | Your job |
|---|---|---|
| Dark mode | Product team | Deliver a branded alternate theme |
| High contrast mode | User and OS | Preserve meaning, structure, and interaction |
In a modern front-end stack, that usually means avoiding one-off HCM patches scattered across stylesheets. Treat it as a system concern. Map forced-color behavior into tokens, keep component APIs state-driven, and make utility layers such as Tailwind express those rules consistently. That approach holds up in production because it scales past a single page or a single theme.
Detecting User Preferences with CSS
There are two CSS features that matter here, and they answer different questions.
One asks, “Is the browser currently forcing colors?” The other asks, “Has the user expressed a contrast preference?” If you mix them up, you’ll either under-style real high contrast scenarios or over-style users who only wanted stronger contrast in your normal theme.

Use forced-colors for system override scenarios
forced-colors is the high signal query for Windows High Contrast Mode style environments.
@media (forced-colors: active) {
.button {
border-color: ButtonText;
color: ButtonText;
background: ButtonFace;
}
}
Use it when you need to respond to the fact that the user agent is actively replacing authored colors. This is the query for survival mode. If your component depends on visible borders, semantic outlines, or icon strokes, you can make the necessary adjustments here.
That’s also why high contrast mode isn’t a substitute for baseline accessible styling. Accessible Web’s guidance on contrast compliance makes the key point that enabling high contrast mode may superficially satisfy contrast needs, but default interface colors still need to meet WCAG 2.1 Success Criterion 1.4.3 with a minimum contrast ratio of 4.5:1 for normal text.
Use prefers-contrast as a preference hint
prefers-contrast is broader. It tells you the user wants more or less contrast, but it doesn’t guarantee forced color remapping.
@media (prefers-contrast: more) {
.card {
box-shadow: none;
border-width: 2px;
}
.muted-text {
color: var(--color-fg-strong);
}
}
Depending on browser support, you may also see values such as less, custom, or no-preference. Treat this query as progressive enhancement. It’s useful for improving your standard theme, not for handling the full forced-colors environment.
A practical detection pattern
In real codebases, I’d split responsibilities like this:
- Base styles first: Meet contrast requirements in your default theme.
- Use
prefers-contrastnext: Strengthen visual separation, reduce subtlety, thicken outlines. - Use
forced-colorsfor hard overrides: Repair controls whose meaning depends on borders, icon strokes, focus rings, and background fills.
A compact example:
.button {
border: 1px solid transparent;
background: var(--button-bg);
color: var(--button-fg);
}
.button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
@media (prefers-contrast: more) {
.button {
border-color: currentColor;
}
}
@media (forced-colors: active) {
.button {
background: ButtonFace;
color: ButtonText;
border-color: ButtonText;
}
.button:focus-visible {
outline-color: Highlight;
}
}
Don’t ask one media query to do both jobs.
forced-colorshandles OS takeover.prefers-contrasthandles preference-aware refinement.
Styling for an Environment with Forced Colors
The hardest part of high contrast mode support is accepting that your favorite CSS tricks won’t carry the component anymore. In forced colors, simple, semantic, sturdy styling wins.

System colors beat brand colors
When forced-colors: active is in play, lean on system color keywords instead of fighting the browser. You want the OS to paint controls using the palette the user selected.
A few useful keywords:
| Purpose | System keyword |
|---|---|
| Page background | Canvas |
| Main text | CanvasText |
| Button background | ButtonFace |
| Button text | ButtonText |
| Links | LinkText |
| Focus and highlights | Highlight |
A practical pattern looks like this:
@media (forced-colors: active) {
.card {
background: Canvas;
color: CanvasText;
border: 1px solid CanvasText;
}
.card a {
color: LinkText;
}
}
If your default theme relies on tinted surfaces and shadow depth, strip that down in forced colors. A clean border is usually more reliable than a layered surface treatment.
The border rules that prevent invisible controls
Problems arise for many custom components. A border that is 0, omitted entirely, or effectively invisible in the normal theme can become a serious problem in high contrast mode.
Harvard’s guidance on High Contrast Mode testing is blunt about it. UI borders need a minimum width of 1px because Windows High Contrast Mode tries to color-code borders to communicate state. If your component has transparent or zero-width borders, the state can become indistinguishable.
That means:
- Buttons need real borders: Even if the visual design is “flat.”
- Inputs need visible edges: Placeholder contrast is not enough.
- Selected and disabled states need structure: Don’t rely on soft fills alone.
.button,
.input,
.select,
.checkbox {
border: 1px solid transparent;
}
That transparent value looks odd at first, but it’s useful. It reserves border geometry in the normal theme and gives forced colors a border to work with.
A transparent 1px border is often the difference between “works in the design review” and “works for actual users.”
SVGs and custom controls need extra care
Custom checkboxes, radio cards, segmented controls, and icon buttons are the usual trouble spots.
For non-decorative SVGs, prefer strokes for boundaries and state cues so the system can preserve perceivable outlines. If your icon depends on a fill that blends into the background after forced color mapping, it may disappear or become a blob.
.icon {
stroke: currentColor;
fill: none;
}
A few safe defaults help a lot:
- Use
currentColor: It keeps icon color tied to text color. - Avoid encoding state only in background fills: Add borders, labels, or checkmarks with strong outlines.
- Keep focus separate from selection: A selected chip and a focused chip need different cues.
Integrating HCM into Modern Design Systems and Theming
If you support high contrast mode by sprinkling one-off CSS fixes across components, the work won’t stick. The first redesign will break it again.
The scalable approach is to model forced colors as a first-class theming concern. Not a separate visual theme in the branding sense, but a tokenized accessibility layer that components can opt into without hand-authored exceptions in every file.
Treat forced colors as a token layer
Most design systems already define semantic tokens such as surface, text, border, accent, focus, and danger. High contrast mode support gets much easier when you map those semantics to system keywords inside a forced-colors context.
For example:
:root {
--ui-surface: var(--color-slate-1);
--ui-text: var(--color-slate-12);
--ui-border: var(--color-slate-6);
--ui-focus: var(--color-blue-8);
}
@media (forced-colors: active) {
:root {
--ui-surface: Canvas;
--ui-text: CanvasText;
--ui-border: CanvasText;
--ui-focus: Highlight;
--ui-link: LinkText;
--ui-button-bg: ButtonFace;
--ui-button-fg: ButtonText;
}
}
Then your component styles stay semantic:
.card {
background: var(--ui-surface);
color: var(--ui-text);
border: 1px solid var(--ui-border);
}
.card:focus-within {
outline: 2px solid var(--ui-focus);
outline-offset: 2px;
}
That’s the same architectural move teams make for dark mode. The difference is that the token values inside forced-colors should align with system color keywords, not branded palette choices.
If your current token model mixes role and appearance, it’s worth cleaning that up. A useful reference point is thinking in semantic layers instead of theme-specific hex values, which is the same direction many teams take when building a component theming system.
Tailwind variants that stay maintainable
Utility-first teams can handle this cleanly too. The goal isn’t to write fewer CSS rules at all costs. The goal is to keep forced-colors behavior close to the component markup without hardcoding visual intent into every instance.
One pattern is a custom variant:
@custom-variant forced-colors (@media (forced-colors: active));
Then component markup can express forced-colors adjustments directly:
<button
class="
inline-flex items-center rounded-md border border-transparent
bg-zinc-900 text-white
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2
forced-colors:bg-[ButtonFace]
forced-colors:text-[ButtonText]
forced-colors:border-[ButtonText]
forced-colors:focus-visible:outline-[Highlight]
"
>
Save
</button>
That works well when your team already thinks in utility layers. It also makes code review easier. A reviewer can see the normal theme contract and the forced-colors contract side by side.
A few guardrails help:
- Keep semantic utilities available:
text-fg,bg-surface,border-subtle,outline-focus. - Reserve raw system keywords for edge cases or token definitions: Otherwise your markup becomes noisy fast.
- Build forced-colors utilities for stateful patterns: focus-visible, selected, disabled, invalid.
Component APIs should expose state, not color decisions
The more your component API asks callers to choose colors, the worse high contrast mode support gets. Consumers shouldn’t need to know how forced colors affects selected tabs or invalid comboboxes.
Expose structural state instead:
<Tab data-selected="true" data-disabled="false" />
Then style state through tokens and media queries:
.tab[data-selected="true"] {
border-color: var(--ui-border-strong);
}
@media (forced-colors: active) {
.tab[data-selected="true"] {
background: Highlight;
color: HighlightText;
}
}
That keeps contrast behavior centralized. It also avoids the classic failure where one product team uses a “ghost” button variant that looked elegant in Figma but collapses in high contrast mode because nobody gave it a border.
A Practical Component Refactor for High Contrast Mode
A lot of guidance about high contrast mode stays abstract. What matters is whether a busy component survives forced colors without turning into a pile of unlabeled rectangles.
Here’s a simplified example. Think of a clickable card with a thumbnail, heading, body copy, and a CTA button.

The before version that breaks
<article class="promo-card">
<img src="/product.jpg" alt="" class="promo-card__image" />
<h3>Upgrade your workspace</h3>
<p>Organize tasks, notes, and handoffs in one place.</p>
<a href="/upgrade" class="promo-card__cta">
Learn more
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M3 8h10M9 4l4 4-4 4" fill="currentColor" />
</svg>
</a>
</article>
.promo-card {
border-radius: 16px;
background: var(--surface-elevated);
box-shadow: var(--shadow-md);
padding: 1rem;
}
.promo-card__cta {
display: inline-flex;
gap: 0.5rem;
background: var(--brand-600);
color: white;
padding: 0.75rem 1rem;
border-radius: 999px;
text-decoration: none;
}
.promo-card__cta:focus-visible {
outline: 2px solid var(--brand-400);
}
Looks good in a normal theme. In high contrast mode, several things can go wrong. The card loses separation because it has no durable border. The pill button can flatten into an ambiguous shape. The icon uses a filled path, which is often less reliable than a stroked icon for preserving direction and boundary.
The refactor that holds up
Start by giving every meaningful interactive boundary actual geometry.
.promo-card {
border: 1px solid transparent;
border-radius: 16px;
background: var(--ui-surface);
color: var(--ui-text);
padding: 1rem;
}
.promo-card__cta {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border: 1px solid transparent;
background: var(--ui-button-bg);
color: var(--ui-button-fg);
padding: 0.75rem 1rem;
border-radius: 999px;
text-decoration: none;
}
.promo-card__cta:focus-visible {
outline: 2px solid var(--ui-focus);
outline-offset: 2px;
}
Then fix the icon and the forced-colors behavior.
.icon {
width: 1rem;
height: 1rem;
stroke: currentColor;
fill: none;
stroke-width: 1.5;
}
@media (forced-colors: active) {
.promo-card {
background: Canvas;
color: CanvasText;
border-color: CanvasText;
box-shadow: none;
}
.promo-card__cta {
background: ButtonFace;
color: ButtonText;
border-color: ButtonText;
}
.promo-card__cta:focus-visible {
outline-color: Highlight;
}
.promo-card__image {
forced-color-adjust: none;
}
}
That last line is selective. Don’t use forced-color-adjust: none casually. I reserve it for elements where keeping the original rendering is essential, and I test it carefully. On decorative images, it may be harmless. On meaningful UI chrome, it can fight the user’s settings.
For a good comparison point, look at a theme switcher component pattern and notice what matters structurally: explicit states, keyboard focus, and a component surface that still reads as interactive when color decisions change upstream.
Here’s a quick walkthrough of the same mindset in motion:
If a component needs a custom explanation to remain understandable in forced colors, it probably needs a simpler visual contract.
How to Test and Audit Your Application for HCM
A component looks finished in Storybook, passes design review, and still falls apart the first time someone runs it under Windows high contrast. The usual failure pattern is predictable. Borders disappear, SVG icons lose meaning, focus rings merge with selection, and a “subtle” disabled state becomes unreadable.
Treat HCM testing as component QA, not a page-level visual pass. In a modern UI stack, that means auditing the tokens, utilities, and component states that produce the interface, because a single bad token or Tailwind pattern can break dozens of surfaces at once.
On Windows, enable the OS high contrast setting and use the product the way a real user would. Tab through forms. Open popovers and menus. Trigger validation. Select rows. Check pressed, expanded, disabled, and current states. Static screenshots miss the exact issues that show up during interaction.
What to inspect first
Start with patterns that depend on color or thin borders to communicate state:
- Surface boundaries: cards, inputs, dialogs, tabs, chips, and segmented controls
- Focus treatment: visible outline, offset, and contrast against both the component and page background
- State clarity: disabled, invalid, selected, expanded, active, and visited
- SVG icon behavior: disclosure arrows, close icons, checkmarks, warnings, and status symbols
- Text links in content: inline links need to remain identifiable without relying on brand color alone
- Selection patterns: tables, listboxes, radio groups, and tree views often lose distinction between focus and selection
If your system uses design tokens, inspect the token mapping before you start patching individual components. Teams often discover that border-subtle, text-muted, or ring utilities were tuned for standard themes and fail in forced colors. Fixing that at the token or utility layer is faster than chasing one-off overrides across the app.
A practical audit pass for component teams
Use this as a release gate for component-heavy screens:
- Test every interactive state with keyboard input. Focus visibility is the first thing to verify.
- Open and close composite components. Menus, comboboxes, dialogs, and accordions fail more often than plain buttons.
- Check border-reliant variants. Ghost buttons, outline badges, pills, and empty inputs need visible edges.
- Confirm icons still communicate meaning. If an icon carries state, it needs to survive forced color remapping.
- Review content areas, not just controls. Helper text, inline links, alerts, and empty states are common misses.
- Run the same checks in a component gallery. A live component playground for state-by-state inspection makes regressions easier to catch before they spread into product screens.
Browser devtools are useful for quick iteration, but they are not enough on their own. Test in the operating system environment, because that is where forced colors, system color keywords, and real keyboard interaction meet your actual component code.
Frequently Asked Questions About High Contrast Mode
Should we ship our own high contrast toggle
Usually, yes, but not as a replacement for OS support.
A custom toggle can help you address product-specific layouts and brand system gaps. It can also improve contrast in places where OS-level overrides don’t fully solve interaction clarity. But it shouldn’t be your only answer, because users who rely on forced colors are already expressing a system-level accessibility preference.
The strongest position is both: respect OS high contrast mode, and offer an app-level contrast theme if your interface is complex enough to benefit from one. PROS discusses this trade-off in design systems, and its practical conclusion is that developers need semantic, color-independent focus states and explicit contrast themes because OS settings alone won’t fix every UI-specific gap.
Is dark mode enough
No.
Dark mode is a product aesthetic and comfort choice. High contrast mode is an accessibility environment that may override your palette entirely. A dark UI with subtle grays, soft shadows, and low-emphasis dividers can still fail badly for users who need strong separation.
Do UI frameworks handle this for us
Only partly.
A framework can give you decent defaults for native buttons, inputs, spacing, and some focus treatment. It won’t save a component whose meaning depends on decorative layers, missing borders, or fragile SVG icons. If you build custom primitives, the responsibility comes right back to your team.
The safest rule is simple: use semantic HTML first, expose state explicitly, and test your actual components under forced colors instead of assuming the framework solved it.
If your team wants a component foundation that already takes accessibility, keyboard behavior, and theming seriously, DOM Studio is worth a look. It gives you headless primitives, Vue-friendly wrappers, and Tailwind-oriented styling workflows that fit modern production apps without forcing you to re-implement the hard interaction details from scratch.
