Error States in Forced Colors Mode
Turn on a high-contrast theme in Windows and look at a typical form’s error state: the red border is gone, the pink background is gone, the red error icon may be gone, and the only thing left is the text — if the text was real text. Forced colors mode (Windows High Contrast and its equivalents) replaces authored colours with a small palette of user-chosen system colours, so any validation state expressed only through colour disappears. People who rely on forced colors — many with low vision or light sensitivity — then cannot tell which field is invalid. This recipe makes error states survive forced colors: it identifies what the browser overrides, expresses invalid state through shape and system colour keywords, keeps icons visible with currentColor, and tests the result with emulation. The underlying messages still come from the Constraint Validation API and are delivered as text, which is what makes all of this possible.
When to Plan for Forced Colors
Plan for it on every form; it costs little. It becomes critical when:
- Your error styling relies on colour — a red border, a pink field background, a coloured icon — as nearly every design does.
- You use background images or box shadows for focus or error rings, which forced colors removes or flattens.
- You render custom controls (styled checkboxes, custom selects) whose states are drawn with authored colours.
- Your audience includes public-sector, enterprise or older users, where high-contrast themes are more common.
The goal is not to style forced colors mode beautifully — users chose their colours deliberately — but to make sure state is still perceivable. The broader visual feedback guidance is in visual feedback and micro-interactions, and the pseudo-class based styling in CSS validation state styling.
Minimal Working Forced-Colors Error Styles
/* Normal themes: colour carries the state, but width and text support it too. */
.field :is(input, select, textarea) {
border: 2px solid var(--field-border, #64748b);
}
.field :is(input, select, textarea):user-invalid {
border-color: var(--error-border, #b91c1c);
box-shadow: 0 0 0 3px var(--error-ring, #fecaca); /* decorative halo */
}
/* Icons drawn with currentColor survive; background-image icons do not. */
.field-error::before {
content: "";
display: inline-block;
inline-size: 1em; block-size: 1em;
margin-inline-end: 0.35em;
background: currentColor;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1 15 14H1zM7.2 6v4h1.6V6zm0 5.2v1.6h1.6v-1.6z'/%3E%3C/svg%3E") center / contain no-repeat;
vertical-align: -0.15em;
}
/* Forced colors: express invalid state with SHAPE, using system colour keywords. */
@media (forced-colors: active) {
.field :is(input, select, textarea):user-invalid {
border-width: 3px;
border-style: dashed; /* shape difference survives any palette */
outline: 2px solid Mark; /* Mark: the system highlight for attention */
outline-offset: 2px;
box-shadow: none; /* removed anyway; don't rely on it */
}
.field-error {
color: CanvasText; /* readable against the user's background */
border-inline-start: 4px solid CanvasText;
padding-inline-start: 0.5rem;
}
.field :is(input, select, textarea):focus-visible {
outline: 3px solid Highlight; /* focus must stay distinct from error */
outline-offset: 2px;
}
}
The invalid field gets a thicker, dashed border and an outline in the system Mark colour, and the message gets a solid bar on its leading edge — differences of shape and weight that remain whatever colours the user has chosen. Focus uses Highlight with a solid outline, so a focused invalid field shows both states distinctly: dashed border for “invalid”, solid outline for “focused”.
Forced Colors Reference
| Keyword / property | Meaning | Use for |
|---|---|---|
@media (forced-colors: active) |
User has a forced palette | Scoping adjustments |
CanvasText / Canvas |
Text and background colours | Messages, bars |
Field / FieldText |
Input background and text | Rarely needed; applied automatically |
Highlight / HighlightText |
Selection / focus colours | Focus outlines |
Mark / MarkText |
Attention highlight | Invalid-state outlines |
LinkText, ButtonText, ButtonBorder |
Links and buttons | Summary links, buttons |
forced-color-adjust: none |
Opt an element out | Only for colour-essential content like swatches |
currentColor |
Inherit the forced text colour | SVG and mask icons |
forced-color-adjust: none is tempting as a shortcut — “just keep my red” — and it is almost always wrong for error states: it overrides a choice the user made to be able to read the page, and your red may be unreadable on their background. Reserve it for content where colour is the content, such as a colour picker’s swatches.
Verification Steps
import { test, expect } from "@playwright/test";
test("invalid state survives forced colors", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto("/signup");
await page.getByRole("button", { name: "Create account" }).click();
const email = page.getByLabel("Email address");
await expect(email).toHaveCSS("border-top-style", "dashed");
await expect(email).toHaveCSS("outline-style", "solid");
await expect(page.locator("#email-err")).toBeVisible();
});
Edge Cases and Failure Modes
Background-image icons. Error icons set with background-image disappear in forced colors. Use inline SVG with fill="currentColor" or a CSS mask over background: currentColor, which inherits the forced text colour.
Box-shadow focus rings. Many design systems draw focus with box-shadow, which forced colors removes — leaving no visible focus at all, a WCAG 2.4.7 failure. Always provide an outline inside the forced-colors block (or use outlines everywhere, with outline-color: transparent in normal themes as a fallback that becomes visible when forced).
Custom checkboxes and radios. Styled replacements drawn with backgrounds lose their checked state. Keep the native control visible in forced colors, or draw the checkmark with currentColor.
Disabled-looking submit buttons. Buttons styled as “disabled” with a grey background become indistinguishable from enabled ones in forced colors. Use the real disabled attribute or aria-disabled with a textual cue, never colour alone.
Inline SVG diagrams in errors. Illustrations with authored fills may become unreadable; give decorative SVGs aria-hidden="true" and do not rely on them for meaning.
Error Summaries and Links in Forced Colors
Error summaries are usually styled with a coloured border or background panel, both of which forced colors flattens into the page background, leaving a list of links floating without a boundary. Give the summary a real border (which becomes a system colour) rather than relying on a tinted background, and keep its heading as actual heading text so its role is clear. Links inside it render in the system LinkText colour automatically; do not override them with authored colours, and keep them underlined so they remain identifiable as links in every palette. The same applies to “needs attention” flags on accordion sections and to success states: every state must have a textual or structural form that survives when colour is taken away.
@media (forced-colors: active) {
.error-summary { border: 3px solid CanvasText; background: Canvas; }
.error-summary a { text-decoration: underline; }
}
A Transparent-Outline Pattern That Works Everywhere
A simple technique makes focus and error rings robust with almost no forced-colors-specific code: give elements a transparent outline in normal themes and draw the visible ring with box-shadow. In normal themes the outline is invisible and the shadow shows; in forced colors the shadow is removed and the browser renders the outline in a system colour, so the ring appears automatically. The same trick works for the error state with a dashed transparent outline.
.field :is(input, select, textarea):focus-visible {
outline: 2px solid transparent; /* becomes visible in forced colors */
box-shadow: 0 0 0 3px var(--focus-ring, #1d4ed8);
}
.field :is(input, select, textarea):user-invalid {
outline: 2px dashed transparent;
outline-offset: 2px;
}
This pattern is recommended by several accessibility teams precisely because it degrades correctly without anyone remembering to write a forced-colors block. It pairs with the non-colour cues described in accessible error shake animation with reduced motion: every signal an error uses should have a form that survives the user’s preferences.
Frequently Asked Questions
Why do my error borders disappear in Windows High Contrast?
Forced colors mode replaces authored colours with the user's system palette, so a border that differs only in colour looks the same as a normal border. Change the border style or width, or add an outline, inside a forced-colors media query.
Should I use forced-color-adjust: none for error states?
No. It overrides colours the user chose to be able to read the page. Use shape, outlines, system colour keywords and text instead; reserve forced-color-adjust: none for content where colour is the information itself.
How do I keep error icons visible in forced colors?
Draw them with currentColor, as inline SVG with fill="currentColor" or a CSS mask over background: currentColor. Background-image icons are removed.
How can I test forced colors without Windows?
Use Chrome DevTools rendering emulation for forced-colors: active, or Playwright's page.emulateMedia({ forcedColors: "active" }), then confirm on a real Windows contrast theme.
Related Guides
- Visual Feedback & Micro-interactions — the wider visual feedback guidance.
- CSS Validation State Styling — pseudo-classes and theming.
- Accessible Error Shake Animation with Reduced Motion — another user preference to respect.
- Automating axe-core Form Audits in CI — automated checks alongside forced-colors tests.