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.

What forced colors keeps and removes A table showing how common error-styling techniques behave in forced colors mode. In forced colors border-color: red replaced by system colour background-color on input replaced by Field colour box-shadow focus / error ring removed background-image icon removed (unless forced-color-adjust: none) border-style / border-width kept outline kept (system colour) Text messages kept (CanvasText)
Colour is replaced, so state must be carried by shape, text and system colours rather than by hue.

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”.

Error state in a normal theme and in forced colors Two columns describing how an invalid field and its message appear in a normal theme and how they must adapt in forced colors mode. Normal theme • red 2px border • pink focus halo (box-shadow) • red text and icon ✓ recognisable by colour and text Forced colors • 3px dashed border (shape) • outline in system Mark colour • message with a solid leading bar ✓ recognisable by shape and text
In forced colors the hue is lost, so the invalid state is carried by a dashed border, an outline and a bar beside the message.

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 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.

How an error state is rendered under forced colors Authored colours are replaced by the user's palette, removed properties drop out, and the remaining shape and text cues carry the invalid state. Authored styles red border, halo, icon Colour replaced system palette applied Removed box-shadow, background-image Remaining cues dashed border, outline, text bar User sees invalid field + message
Design the error state so that what remains after colour replacement — shape, outline and text — is still unambiguous.

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.

← Back to Visual Feedback & Micro-interactions