Forced Colors Mode and Animations
Some users replace every colour on the web with a small palette of their own. Windows contrast themes are the best-known example: the operating system tells the browser to render text, backgrounds, links, buttons and borders in a handful of system colours, chosen by the user for readability. The browser complies by overriding author colours — and by removing box shadows, text shadows and gradient backgrounds. Layout, transforms and opacity are untouched, so most animations still run, but any animation that communicates through colour, shadow or gradient loses its message. This page covers what forced colors mode overrides, which common micro-interactions break, and how to restore them with a few targeted rules. It belongs to Accessibility in CSS Animations in the CSS-Only Micro-Interactions & Animations guide.
What the browser overrides
In forced colors mode, a defined set of properties is replaced at computed-value time. Colours — color, background-color, border-color, outline-color, fill, stroke and a few more — take system colours. box-shadow and text-shadow become none. background-image is removed unless it is an image loaded from a URL, so gradients disappear. Everything else keeps its author value: transform, translate, scale, opacity, clip-path, sizes and positions all behave normally.
The key insight is in the bottom-right entry: border and outline colours are forced, but their widths and styles are not. A border that appears, thickens or changes from dashed to solid is still visible, drawn in the system's border colour. That is the main tool for restoring state indicators.
Micro-interactions that break
Four patterns account for most failures:
- Box-shadow focus rings. A glowing
box-shadow: 0 0 0 3pxfocus ring vanishes. Keyboard users lose their place entirely — a failure of WCAG 2.4.7. - Colour-only hover and selected states. A card whose hover state only shifts its background, or a tab whose selected state is only a coloured underline drawn with
background, has no visible change. - Gradient-based loaders. Shimmer skeletons and animated gradient borders are
background-imageeffects; they are removed, so the loader shows as a static empty box with no sign of progress. - Shadow-based depth. Pressed buttons that communicate state through a collapsing shadow lose the effect, though their translation remains.
The complete implementation
The demo shows a card with a focus ring, a selected state and a loading shimmer, each with a forced-colors fallback in the code below. Normal rendering is shown in the demo; open it with forced colors emulated in DevTools to see the fallbacks take over.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Forced colors fallbacks</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 2rem; display: grid; gap: 1rem; max-inline-size: 26rem; }
/* 1. Focus ring: shadow for style, transparent outline for forced colors. */
.option {
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 10px;
background: #fff;
transition: box-shadow 150ms ease-out, border-color 150ms ease-out;
}
.option:focus-visible {
box-shadow: 0 0 0 4px rgb(37 99 235 / 0.35);
outline: 2px solid transparent; /* becomes visible when colours are forced */
outline-offset: 2px;
}
/* 2. Selected state: colour plus a border style change. */
.option[aria-pressed="true"] {
border-color: #2563eb;
background: #eff6ff;
}
/* 3. Shimmer loader with a structural fallback. */
.skeleton {
block-size: 1rem;
border-radius: 4px;
background: linear-gradient(90deg, #e2e8f0 25%, #f8fafc 50%, #e2e8f0 75%) 0 0 / 200% 100%;
animation: shimmer 1.4s linear infinite;
}
@keyframes shimmer { to { background-position: -200% 0; } }
@media (forced-colors: active) {
.option[aria-pressed="true"] {
border-style: double;
border-width: 4px; /* width and style survive forcing */
}
.skeleton {
border: 1px solid CanvasText;
animation: pulse 1.4s ease-in-out infinite alternate;
}
@keyframes pulse { from { opacity: 1; } to { opacity: 0.4; } }
}
@media (prefers-reduced-motion: reduce) {
.skeleton { animation: none; }
}
</style>
</head>
<body>
<button class="option" type="button" aria-pressed="true">Monthly billing</button>
<button class="option" type="button" aria-pressed="false">Annual billing</button>
<div class="skeleton" role="status" aria-label="Loading"></div>
</body>
</html>
Each fallback swaps a forced property for a preserved one. The focus rule draws a shadow in normal rendering and also sets a transparent outline; forced colors mode replaces transparent with a visible system colour, so the ring appears automatically, without a media query. The selected state adds a thicker, double border. The skeleton, whose gradient disappears, gains a system-coloured border and switches to an opacity pulse, which forced colors leaves alone.
The key technique: the transparent outline
The transparent outline trick deserves its own emphasis because it fixes the single most harmful failure with one line. outline: 2px solid transparent is invisible in normal rendering, so it costs nothing visually. In forced colors mode, the browser replaces author colours — including transparent in outlines and borders — with the appropriate system colour, and a solid 2-pixel outline appears exactly where the focus ring was. Put it in the same :focus-visible rule as the shadow — never on the element's resting state, or every element would show a ring when colours are forced.
System colours for custom components
Inside @media (forced-colors: active), use CSS system colour keywords rather than hex values so the component follows the user's palette: Canvas and CanvasText for surfaces and text, LinkText for links, ButtonFace and ButtonText for buttons, Highlight and HighlightText for selected items, GrayText for disabled content. A custom toggle switch whose track is drawn with a background colour, for example, needs border: 1px solid ButtonText on the track and background: Highlight on the thumb when checked. The browser already forces these properties, but naming the keyword makes the mapping deliberate rather than whatever the automatic override chooses.
forced-color-adjust: none opts an element out of forcing altogether. Reserve it for elements where author colour is the content — a colour picker's swatches, a chart legend key — and keep it off containers, because it applies to descendants too.
A testing checklist
With forced colors emulated, walk through each interactive component and check that every state change is still perceivable:
- Tab through the page. Every focusable element should show a visible ring. Missing rings almost always mean a
box-shadowfocus style without an outline. - Hover and select. Cards, tabs, list rows and toggles should still show which item is hovered and which is selected. Where only a background changed, add a border, an outline or an underline.
- Trigger loading states. Skeletons, spinners drawn with borders and progress bars should still show activity. Spinners built from
border-top-colordifferences become a full, uniform circle because every border side gets the same system colour; a spinner that rotates a dashed or partial border, or an SVG arc, survives. - Open overlays. Dialogs and popovers that rely on a shadow to separate them from the page need a border in forced colors mode, or they blend into the content behind them.
- Check icons. Inline SVG icons using
fill: currentColorfollow the forced text colour and remain visible; icons with hard-coded fills are forced too, but icons delivered as background images may be removed.
Each fix is usually one or two declarations inside the media query. The effort is small compared with the cost of an interface that becomes unusable for people who depend on their contrast theme.
Motion itself is unaffected
Forced colors mode is about colour, not motion. Users who choose a contrast theme have not necessarily asked for reduced motion, so opacity and transform animations should keep running unless prefers-reduced-motion: reduce is also set. The two preferences combine independently, which is why the implementation has both media queries: the skeleton pulses in forced colors mode and stops entirely when motion is reduced. prefers-reduced-motion Recipes covers that side.
Browser support
The forced-colors media feature is supported in Chrome 89+, Edge 79+, Firefox 89+ and Safari 16+. Forced colors mode itself is activated by the operating system: Windows contrast themes are the most common source, and Chromium DevTools can emulate it from the Rendering panel. prefers-reduced-motion is supported in Chrome 74+, Edge 79+, Firefox 63+ and Safari 10.1+.
FAQ
Do CSS animations run in forced colors mode?
Yes. Animations and transitions keep running, but the forced properties, such as color, background-color, border-color and box-shadow, are overridden with the user's system colours. An animation that only changes a forced colour becomes invisible.
Why did my focus ring disappear in high contrast mode?
Focus rings drawn with box-shadow are removed, because box-shadow is forced to none. Use outline instead, or add a transparent outline alongside the shadow: forced colors mode replaces the transparent colour with a visible system colour.
Should I use forced-color-adjust: none? Only on small, specific elements whose colours carry meaning the system palette cannot express, such as a colour swatch picker. Applying it broadly overrides the user's chosen theme, which defeats the purpose of the setting.
How do I test forced colors without Windows?
Chromium DevTools can emulate forced-colors: active from the Rendering panel. It is close enough for development; confirm important flows with a real contrast theme on Windows before release.
Related
- Accessibility in CSS Animations — the parent guide.
- Creating Accessible Focus Indicators — outline-based focus design.
- CSS-Only Loading Spinners and Skeletons — loaders that need these fallbacks.
- Animated Gradient Borders With @property — an effect removed by forced colors.
Related articles
More pages in the same section.