
Building Custom Checkboxes and Radio Buttons with CSS
Checkboxes and radio buttons are tiny, but they show up everywhere: sign-up forms, settings pages, filters, surveys, and checkout flows. And native controls rarely match a design system. They're small, their shape varies by platform, and until recently you couldn't even change their color.
For years, the standard workaround was to hide the real input and build a fake one out of span elements and background images. That worked, but it often broke keyboard navigation, focus rings, and screen reader support. Modern CSS gives us a much better path: style the real <input> element directly with appearance: none. In this guide we'll build custom checkboxes, radio buttons, and a toggle switch that look exactly how you want and keep every bit of native accessibility.
Start with the Simplest Option: accent-color
Before building anything custom, check whether you actually need to. The accent-color property recolors native checkboxes and radios in every modern browser:
:root {
accent-color: #4f46e5;
}
input[type="checkbox"],
input[type="radio"] {
width: 1.25rem;
height: 1.25rem;
}
That's two rules for brand-colored, correctly sized, fully accessible controls. The browser even picks a contrasting checkmark color automatically. If your design only calls for a different color and size, stop here.
When you need a custom shape, border, checkmark, animation, or a toggle-switch look, read on.
Why Style the Real Input?
The old technique looked something like this:
<!-- The old way: don't do this -->
<label class="fake-checkbox">
<input type="checkbox" class="visually-hidden" />
<span class="fake-checkbox__box"></span>
Subscribe
</label>
It can work, but every piece of behavior depends on getting the hidden input and the fake box perfectly in sync: focus styles, disabled states, high contrast mode, and hit areas. It's easy to get one wrong.
With appearance: none, the browser stops drawing the native control but keeps the element itself. It's still a real checkbox: it receives focus, toggles with Space, participates in forms, announces its state to assistive technology, and matches :checked, :disabled, and :indeterminate. You just paint it yourself.
The Markup
Keep the HTML semantic and simple. Every control needs a label:
<fieldset class="choice-group">
<legend>Notifications</legend>
<label class="choice">
<input
type="checkbox"
class="checkbox"
name="notify"
value="email"
checked
/>
Email updates
</label>
<label class="choice">
<input type="checkbox" class="checkbox" name="notify" value="sms" />
Text messages
</label>
<label class="choice">
<input
type="checkbox"
class="checkbox"
name="notify"
value="push"
disabled
/>
Push notifications (coming soon)
</label>
</fieldset>
Wrapping the input in its label makes the text clickable, which gives users a much larger target. Using fieldset and legend groups related options so screen readers announce the group name.
Building a Custom Checkbox
Step 1: Reset and Size the Box
:root {
--control-size: 1.25rem;
--control-border: #9ca3af;
--control-accent: #4f46e5;
--control-bg: #ffffff;
--control-focus: #38bdf8;
}
.choice {
display: flex;
align-items: center;
gap: 0.625rem;
padding-block: 0.25rem;
cursor: pointer;
}
.checkbox {
appearance: none;
flex-shrink: 0;
display: grid;
place-content: center;
width: var(--control-size);
height: var(--control-size);
margin: 0;
border: 2px solid var(--control-border);
border-radius: 0.3rem;
background: var(--control-bg);
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
display: grid with place-content: center perfectly centers the checkmark we're about to add. flex-shrink: 0 stops the box from getting squashed when the label text wraps onto multiple lines.
Step 2: Draw the Checkmark
Pseudo-elements on <input> elements aren't supported consistently, but with appearance: none, ::before does render on checkboxes and radios in all current major browsers. That's the approach most modern design systems use:
.checkbox::before {
content: "";
width: 0.7em;
height: 0.7em;
background: #ffffff;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
transform: scale(0);
transition: transform 0.15s ease-in-out;
}
.checkbox:checked {
background: var(--control-accent);
border-color: var(--control-accent);
}
.checkbox:checked::before {
transform: scale(1);
}
The clip-path polygon cuts a checkmark shape out of a small white square. Scaling it from 0 to 1 gives a subtle pop-in animation when checked.
If you'd rather use an SVG icon, a mask works just as well and is easier to swap:
.checkbox::before {
content: "";
width: 0.8em;
height: 0.8em;
background: #ffffff;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E")
center / contain no-repeat;
transform: scale(0);
transition: transform 0.15s ease-in-out;
}
Because the icon is a mask, its color comes from background, so it's easy to theme.
Step 3: Focus, Hover, and Disabled States
.checkbox:focus-visible {
outline: 3px solid var(--control-focus);
outline-offset: 2px;
}
.choice:hover .checkbox:not(:disabled, :checked) {
border-color: var(--control-accent);
}
.checkbox:disabled {
background: #f3f4f6;
border-color: #d1d5db;
cursor: not-allowed;
}
.checkbox:disabled:checked {
background: #c7d2fe;
border-color: #c7d2fe;
}
.choice:has(.checkbox:disabled) {
color: #9ca3af;
cursor: not-allowed;
}
The :has() selector lets the whole label dim when its checkbox is disabled, with no extra classes in the markup.
Step 4: The Indeterminate State
Checkboxes have a third state, indeterminate, used for "select all" controls when only some children are checked. It can only be set from JavaScript:
const selectAll = document.querySelector("#select-all");
selectAll.indeterminate = true;
Style it with the :indeterminate pseudo-class, drawing a horizontal bar instead of a checkmark:
.checkbox:indeterminate {
background: var(--control-accent);
border-color: var(--control-accent);
}
.checkbox:indeterminate::before {
clip-path: inset(40% 10% 40% 10%);
transform: scale(1);
}
Screen readers announce it as "mixed" or "partially checked" automatically, because it's still a real checkbox.
Building a Custom Radio Button
Radios use the same technique with a circle and a dot:
<fieldset class="choice-group">
<legend>Billing cycle</legend>
<label class="choice">
<input type="radio" class="radio" name="billing" value="monthly" checked />
Monthly
</label>
<label class="choice">
<input type="radio" class="radio" name="billing" value="yearly" />
Yearly (save 20%)
</label>
</fieldset>
.radio {
appearance: none;
flex-shrink: 0;
display: grid;
place-content: center;
width: var(--control-size);
height: var(--control-size);
margin: 0;
border: 2px solid var(--control-border);
border-radius: 50%;
background: var(--control-bg);
cursor: pointer;
transition: border-color 0.15s ease;
}
.radio::before {
content: "";
width: 0.55em;
height: 0.55em;
border-radius: 50%;
background: var(--control-accent);
transform: scale(0);
transition: transform 0.15s ease-in-out;
}
.radio:checked {
border-color: var(--control-accent);
}
.radio:checked::before {
transform: scale(1);
}
.radio:focus-visible {
outline: 3px solid var(--control-focus);
outline-offset: 2px;
}
Keyboard behavior comes for free: Tab moves focus into the group, arrow keys move between options, and only the selected radio is in the tab order. That's exactly the behavior users expect, and it's very hard to reproduce with fake controls.
Building a Toggle Switch
A toggle switch is just a checkbox with a different skin. Add role="switch" so screen readers announce it as "on/off" rather than "checked/not checked":
<label class="switch">
<input type="checkbox" class="switch__input" role="switch" />
Dark mode
</label>
.switch {
display: inline-flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
}
.switch__input {
appearance: none;
position: relative;
flex-shrink: 0;
width: 2.75rem;
height: 1.5rem;
margin: 0;
border-radius: 999px;
background: #d1d5db;
cursor: pointer;
transition: background-color 0.2s ease;
}
.switch__input::before {
content: "";
position: absolute;
top: 0.1875rem;
left: 0.1875rem;
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
transition: translate 0.2s ease;
}
.switch__input:checked {
background: var(--control-accent);
}
.switch__input:checked::before {
translate: 1.25rem 0;
}
.switch__input:focus-visible {
outline: 3px solid var(--control-focus);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.switch__input,
.switch__input::before {
transition: none;
}
}
The knob moves by exactly the track width minus the knob width minus both insets: 2.75rem - 1.125rem - 0.375rem = 1.25rem. If you change the sizes, recompute that value, or express it with calc() using custom properties.
Safari has also shipped a native switch control via a switch attribute on checkbox inputs. It's not supported elsewhere yet, and browsers that don't recognize it simply ignore it, so the custom version above remains the portable choice.
Card-Style Options
For pricing plans or shipping methods, users often prefer large, clickable cards. The same radio can power them, with :has() styling the card:
<div class="plan-options">
<label class="plan">
<input type="radio" class="radio" name="plan" value="starter" checked />
<span class="plan__name">Starter</span>
<span class="plan__price">$9/mo</span>
</label>
<label class="plan">
<input type="radio" class="radio" name="plan" value="pro" />
<span class="plan__name">Pro</span>
<span class="plan__price">$29/mo</span>
</label>
</div>
.plan-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 1rem;
}
.plan {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border: 2px solid #e5e7eb;
border-radius: 0.75rem;
cursor: pointer;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.plan:has(:checked) {
border-color: var(--control-accent);
background: color-mix(in srgb, var(--control-accent) 6%, white);
}
.plan:has(:focus-visible) {
outline: 3px solid var(--control-focus);
outline-offset: 2px;
}
.plan__name {
font-weight: 600;
}
The radio stays visible inside the card, which is good practice: it tells users at a glance that this is a single-choice group.
Supporting High Contrast Mode
In Windows forced colors mode, custom backgrounds are overridden with system colors, and your checkmark could vanish. Add a small block to keep the controls usable:
@media (forced-colors: active) {
.checkbox,
.radio,
.switch__input {
border: 2px solid ButtonText;
}
.checkbox::before,
.radio::before,
.switch__input::before {
background: CanvasText;
}
.checkbox:checked,
.radio:checked,
.switch__input:checked {
border-color: Highlight;
}
}
Common Pitfalls
- Using
display: noneorvisibility: hiddenon the input. That removes it from the keyboard and accessibility tree. Withappearance: none, you never need to hide it. - Forgetting labels. Every checkbox and radio needs an associated
label, either wrapping or linked withfor. - Tiny hit areas. Keep the control at least 20px and make the label clickable. Touch guidelines recommend a target around 44px, which the label padding can provide.
- Relying on color alone. The checked state should change shape (a checkmark or dot), not just color, so it's clear to color-blind users.
- Missing
role="switch"on toggles, which leaves screen reader users hearing "checkbox" for something that looks like a switch.
Conclusion
Custom checkboxes and radio buttons no longer require hidden inputs and fake spans. Set appearance: none on the real element, draw the box and indicator with borders and a ::before pseudo-element, and style :checked, :indeterminate, :disabled, and :focus-visible. Keyboard support, form submission, and screen reader announcements all keep working because the control is still native underneath.
Start with accent-color when color is all you need, reach for appearance: none when your design needs a custom shape, and use :has() to style labels and option cards based on the control's state. You get the look your design calls for, and your users keep all the behavior they rely on.


