Stop Shipping Custom UI That Disappears in forced-colors Mode
Windows High Contrast Mode doesn't just boost contrast — it strips your backgrounds, shadows, and colors outright. Here's what `forced-colors` actually does, why a styled <div> vanishes while a real <button> survives, and the fix.

A QA ticket landed with a screenshot: "Submit button missing on checkout." I opened the page in Chrome. The button was right there — blue, rounded, impossible to miss. I opened it in Firefox. Same button. I assumed a bad screenshot and closed the ticket.
Three days later it came back, same screenshot, same page, from a different tester. This time I asked what they'd changed. "Nothing," they said. "I just use Windows with High Contrast on, because I'm light-sensitive." I flipped that setting on my own machine for the first time in years, reloaded the page, and watched my button disappear into the background like it had never existed.
It wasn't a rendering bug. It wasn't a z-index fight. It was a CSS feature I'd never once had to think about, quietly rewriting my entire color palette out from under me.
My first guess was contrast. The button used background: #2563eb on a white card — readable, passed every automated contrast checker I ran. So I assumed High Contrast Mode was some kind of amplifier: it probably just needed more contrast to work with. I darkened the blue, added a heavier box-shadow for depth, bumped the border a shade, and shipped it.
The next build, the button was still gone.
That's the part that should have told me something was off: I hadn't made the colors fail a contrast check, I'd made them more correct by every metric I knew how to check — and the bug didn't budge. If contrast were the problem, a darker blue should have helped. It changed nothing, because the browser was never reading my blue in the first place.
Windows High Contrast Mode doesn't ask the browser to render your page with better contrast. It tells the browser to render your page using the operating system's own fixed palette — full stop, author CSS or not. Every modern browser exposes this as a media feature:
It only ever matches when the user has flipped an OS-level switch (Windows High Contrast themes are the classic trigger) — you can't turn it on with JavaScript, and prefers-contrast is a different, much gentler feature that just reports a preference for more or less contrast without touching your styles at all. forced-colors is not a hint. When it's active, the browser overrides nearly every color-related property you wrote — color, background-color, border-color, outline-color — and remaps each one onto one of a couple dozen system colors, chosen by what kind of element it's on. Normal text becomes CanvasText on a Canvas background. A button's label becomes ButtonText. None of that is something your stylesheet gets a vote on.
Two more things get treated even more bluntly: box-shadow and text-shadow are forced to none, and any background-image that isn't a url() is forced to none too. There's no sensible way to remap a drop shadow or a gradient onto a small fixed palette, so the browser doesn't try — it just removes them.
Here's the part that explains my exact bug. My button's shape came from background: #2563eb plus a box-shadow for depth, and it had border: none. Under forced colors: the background got remapped to some flat system color, the shadow vanished outright, and because I'd never declared an actual border, there was nothing left for the browser to recolor into a visible edge. The button didn't get uglier — it got structurally invisible, blending straight into the page.
Quick gut check before you keep reading: if a native <button> has the exact same styles — background, box-shadow, border: none — why does it usually survive forced colors when a <div> styled to look like a button doesn't?
The difference isn't that <button> is magic. It's that most "reset" button styles only remove the parts they're actively replacing, and browsers ship a default border on form controls in their user-agent stylesheet. Unless you've specifically zeroed that out, there's still a border on a native <button> for forced colors to recolor into something visible. A <div> built from scratch as a custom checkbox or button usually never had a border at all — its shape existed only in the properties forced colors throws away.
This is a known enough pattern that there's a standing trick for it: give decorative edges a border: 1px solid transparent instead of border: none in your normal styles. Visually, nothing changes — a transparent border is invisible either way. But forced colors mode remaps border colors, including transparent, onto the system palette. You get a free, correctly colored boundary in forced-colors mode without touching how the component looks the other 99% of the time anyone views it.
The reliable fix isn't fighting the override — it's meeting it, explicitly, with the same palette the browser is already using. System color keywords like ButtonBorder, ButtonFace, Highlight, and CanvasText are valid CSS values any time, and forced colors mode treats them as already correct, so it leaves them alone instead of remapping them to something else:
Nothing in the normal-mode styles changes. Inside the media query, the checkbox gets an explicit border using a keyword that already means "the border color controls should have," and its checked state uses Highlight, the same system color the browser uses for selected text and active UI. The result matches how every native control on that user's machine already looks — which is the entire point of the feature.
There's one more lever worth knowing, and it's a trap if you reach for it too fast: forced-color-adjust: none opts an element out of all of this, so your original colors render exactly as written, forced colors mode or not — in browsers that support the property. Unlike forced-colors itself, forced-color-adjust isn't Baseline yet: Chrome and Firefox support it, but Safari doesn't, as of this writing — so don't treat it as a guaranteed universal escape hatch, and still test the element without it. It's tempting to slap it on the whole page the moment you see something break. Don't. The moment you opt out, you own the contrast and visibility guarantees the user just told their OS they need — that's appropriate for a brand logo or a purely decorative illustration, and actively harmful on real text or an interactive control. If you ever use it, use it narrowly, and never on anything the user has to read or click.
The quickest way to see all of this without touching a single Windows setting: Chromium-based DevTools (Chrome, Edge) can emulate forced colors mode directly — open the Rendering tab and look for the CSS media feature emulation options, flip forced-colors to "active," and reload. Every property swap in this post happens live, on your own page, in your own browser.
Runs right in your browser — poke at it and watch the concept react live.
I didn't rewrite the design system. I added one @media (forced-colors: active) block per component that relied on shadows or background color for its shape, each one just a border and maybe a background swap using system keywords. The checkout button that started this whole thing now has four extra lines of CSS. It's visible to every High Contrast user it was invisible to before, and it looks exactly the same to everyone else.
The lesson that actually stuck with me isn't about this one bug. It's that "I tested it and it looks fine" has a hidden asterisk: fine in whose color scheme. Dark mode earned a place in every component checklist a few years ago. Forced colors deserves the same five-second check before you call something done — because the first time you hear about it, like me, it'll be from someone who can't see your product at all.
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
Have you checked your own components in forced-colors mode yet? Open DevTools, flip the emulation on, and see what quietly vanishes — I'd genuinely like to know what you find.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.