Custom radio buttons that survive high contrast mode
Styling the real input with appearance: none, and making sure it doesn't vanish when Windows forced colours kicks in.
Most custom radio buttons hide the real input and draw a fake one with background colours. It looks lovely until someone turns on a Windows contrast theme. Forced colours mode swaps your colours for the person’s own palette, and the fake radio can disappear completely, taking the selected state with it. I built the version below at work, before we had a dedicated accessibility team.
Keep the real input
Leave the input in place and style it directly with appearance: none. A hidden input plus a decorated <span> has to recreate everything the browser already does, and it rarely gets all of it. The native control keeps its behaviour for free: arrow keys move between options in a group, the value goes with the form, and assistive technology hears a radio button with its checked state.
input[type="radio"] {
appearance: none;
width: 1.5rem;
height: 1.5rem;
border: 2px solid currentColor;
border-radius: 50%;
display: grid;
place-content: center;
}
input[type="radio"]::before {
content: "";
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
transform: scale(0);
box-shadow: inset 1em 1em currentColor;
}
input[type="radio"]:checked::before {
transform: scale(1);
} The border uses currentColor, so it follows the text colour in every theme, light, dark or anything else. The dot is a pseudo-element painted with an inset box-shadow, and it scales from nothing to full size when the input is checked. Sizing in rem means the control grows with the person’s font size along with the label next to it.
That box-shadow is the weak spot. It looks right everywhere except forced colours, where browsers remove box shadows altogether. The ring survives and the dot doesn’t, so every option looks unchecked.
Fix the dot for forced colours
@media (forced-colors: active) {
input[type="radio"]::before {
forced-color-adjust: none;
background: CanvasText;
box-shadow: none;
}
input[type="radio"]:focus-visible {
outline: 3px solid Highlight;
}
} Inside the media query the dot gets a solid background instead. forced-color-adjust: none stops the browser overriding that background, and CanvasText is a system colour, so the dot still matches whatever palette the person has picked. Highlight does the same job for the focus ring, which makes the focused option stand out in the colour their theme already uses for focus and selection.
It’s worth being sparing with forced-color-adjust: none. It opts an element out of the person’s choices, so I keep it to the one pseudo-element that needs it and only ever pair it with a system colour.
Testing it for real
Chrome DevTools can emulate forced colours under Rendering, which is much faster than switching Windows themes every time you change a line. It’s a good first check. I tested the finished radios in Windows high contrast mode, with NVDA, and with JAWS, which I ran over remote desktop from a laptop (not glamorous). For mobile, I used BrowserStack to try them with VoiceOver on iOS devices.
Whatever you test with, check the unchecked, checked, focused and disabled states in both light and dark contrast themes. With this pattern, checked is the state to watch, because that’s where the box-shadow was doing the work.
tip!
Size the hit area, not just the dot
Wrap each radio and its label in one padded element so the whole row is clickable, and comfortably clears WCAG 2.2’s 24 by 24 pixel target size.
A <label> wrapping the input already makes its text clickable, so the padding on that wrapper is all the extra hit area needs.