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

ChromeFirefoxIEEdgeSafari
83*80No83*15.4

Mobile / tablet

Android ChromeAndroid FirefoxAndroidiOS Safari
15115315115.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.