Custom checkboxes, radio buttons, and toggle switches can be built today without a line of JavaScript or extra markup, and the technique has only gotten simpler.
What changed
The ::before and ::after pseudo-elements can now be styled on the <input> element itself. That removes the extra <div> or <span> that custom designs previously required, while keeping a real <input> in place.
Markup
Nothing special is needed here; the inputs are styled with this HTML alone.
<!-- Checkbox -->
<input type="checkbox">
<!-- Radio -->
<input type="radio">
<!-- Switch -->
<input type="checkbox" class="switch">
Adding name and id attributes plus a matching <label> element is still recommended:
<!-- Checkbox -->
<input type="checkbox" name="c1" id="c1">
<label for="c1">Checkbox</label>
<!-- Radio -->
<input type="radio" name="r1" id="r1">
<label for="r1">Radio</label>
<!-- Switch -->
<input type="checkbox" class="switch" name="s1" id="s1">
<label for="s1">Switch</label>
Styling the input
The first step is checking support for appearance: none; and its prefixed companions. appearance removes a browser's default styling from an element. Where it isn't supported, the styles don't apply and the default input styles remain — an acceptable outcome for progressive enhancement.
@supports(-webkit-appearance: none) or (-moz-appearance: none) {
input[type='checkbox'],
input[type='radio'] {
-webkit-appearance: none;
-moz-appearance: none;
}
}
The property is still a working draft. Its support at the time of writing breaks down as follows:
Desktop
| Chrome | Firefox | IE | Edge | Safari |
|---|---|---|---|---|
| 83* | 80 | No | 83* | 15.4 |
Mobile / tablet
| Android Chrome | Android Firefox | Android | iOS Safari |
|---|---|---|---|
| 151 | 153 | 151 | 15.4 |
States to cover
Form elements, like links, have interactive states to account for:
:checked:hover:focus:disabled
Below, the toggle input is styled, the knob created, and the :checked state handled:
/* The toggle container */
.switch {
width: 38px;
border-radius: 11px;
}
/* The toggle knob */
.switch::after {
left: 2px;
top: 2px;
border-radius: 50%;
width: 15px;
height: 15px;
background: var(--ab, var(--border));
transform: translateX(var(--x, 0));
}
/* Change color and position when checked */
.switch:checked {
--ab: var(--active-inner);
--x: 17px;
}
/* Drop the opacity of the toggle knob when the input is disabled */
.switch:disabled:not(:checked)::after {
opacity: .6;
}
Here the <input> acts as a container, and its ::after pseudo-element becomes the knob — no further markup required.
The demo styles define CSS custom properties, a convenient way to manage reusable values in a stylesheet:
@supports(-webkit-appearance: none) or (-moz-appearance: none) {
input[type='checkbox'],
input[type='radio'] {
--active: #275EFE;
--active-inner: #fff;
--focus: 2px rgba(39, 94, 254, .25);
--border: #BBC1E1;
--border-hover: #275EFE;
--background: #fff;
--disabled: #F6F8FF;
--disabled-inner: #E1E6F9;
}
}
Custom properties also make it easy to update values based on an element's state:
/* Default */
input[type='checkbox'],
input[type='radio'] {
--active: #275EFE;
--border: #BBC1E1;
border: 1px solid var(--bc, var(--border));
}
/* Override defaults */
input[type='checkbox']:checked,
input[type='radio']:checked {
--b: var(--active);
--bc: var(--active);
}
/* Apply another border color on hover if not checked & not disabled */
input[type='checkbox']:not(:checked):not(:disabled):hover,
input[type='radio']:not(:checked):not(:disabled):hover {
--bc: var(--border-hover);
}
Focus and labels
A custom focus style should be part of the design. The default outline is removed because it can't be rounded to match the rest of the element; a border-radius plus a box-shadow produces a rounded substitute that behaves like an outline.
input[type='checkbox'],
input[type='radio'] {
--focus: 2px rgba(39, 94, 254, .25);
outline: none;
transition: box-shadow .2s;
}
input[type='checkbox']:focus,
input[type='radio']:focus {
box-shadow: 0 0 0 var(--focus);
}
The <label> that directly follows the <input> can be aligned and styled too:
<input type="checkbox" name="c1" id="c1">
<label for="c1">Checkbox</label>
input[type='checkbox'] + label,
input[type='radio'] + label {
display: inline-block;
vertical-align: top;
/* Additional styling */
}
input[type='checkbox']:disabled + label,
input[type='radio']:disabled + label {
cursor: not-allowed;
}
The net result: custom form styles take less markup, thanks to pseudo-elements that sit directly on form inputs; less style switching, thanks to custom properties; and hold up well in browsers, thanks to @supports.



