Pure CSS Custom Checkbox Style

We'll create custom, cross-browser, theme-able, scalable checkboxes in pure CSS with the following:

  • currentColor and CSS custom properties for theme-ability
  • em units for relative sizing
  • use of pseudo elements for the :checked indicator
  • CSS grid layout to align the input and label

Many of the concepts here overlap with our custom styled radio buttons from episode 18, with the addition of styling for the :disabled state

Now available: my egghead video course Accessible Cross-Browser CSS Form Styling. You'll learn to take the techniques described in this tutorial to the next level by creating a themable form design system to extend across your projects.

Checkbox HTML

In the radio buttons article, we explored the two valid ways to markup input fields. Much like then, we will select the method where the label wraps the input.

Here's our base HTML for testing both an unchecked and checked state:

<label class="form-control">
  <input type="checkbox" name="checkbox" />
  Checkbox
</label>

<label class="form-control">
  <input type="checkbox" name="checkbox-checked" checked />
  Checkbox - checked
</label>

Common Issues With Native Checkboxes

As with the radio buttons, the checkbox appearance varies across browsers.

Here's the base styles across (in order from left) Chrome, Firefox, and Safari:

default checkboxes in Chrome, Firefox, and Safari

Also like with radio buttons, the checkbox doesn't scale along with the font-size.

Our solution will accomplish the following goals:

  • scale with the font-size provided to the label
  • gain the same color as provided to the label for ease of theme-ability
  • achieve a consistent, cross-browser design style, including :focus state
  • maintain keyboard and color contrast accessibility

Our styles will begin with the same variable and reset as used for the radio buttons

Label Styles

Our label uses the class of .form-control. The base styles we'll include here are font styles. Recall from earlier that the font-size will not yet have an effect on the visual size of the checkbox input.

CSS for ".form-control font styles"
.form-control {
  font-family: system-ui, sans-serif;
  font-size: 2rem;
  font-weight: bold;
  line-height: 1.1;
}

We're using an abnormally large font-size just to emphasize the visual changes for purposes of the tutorial demo.

Our label is also the layout container for our design, and we're going to set it up to use CSS grid layout to take advantage of gap.

CSS for ".form-control grid layout"
.form-control {
  font-family: system-ui, sans-serif;
  font-size: 2rem;
  font-weight: bold;
  line-height: 1.1;
  display: grid;
  grid-template-columns: 1em auto;
  gap: 0.5em;
}

Custom Checkbox Style

Alright, now we'll get into restyling the checkbox to be our custom control.

The original version of this tutorial demonstrated use of extra elements to achieve the desired effect. Thanks to improved support of appearance: none and with appreciation to Scott O'Hara's post on styling radio buttons and checkboxes, we can rely on pseudo elements instead!

Join my newsletter for article updates, CSS tips, and front-end resources!

Step 1: Hide the Native Checkbox Input

We need to reset the native checkbox input styles, but keep it interactive to enable proper keyboard interaction and also to maintain access to the :focus state.

To accomplish this, we only need to set appearance: none. This removes nearly all inherited browser styles and gives us access to styling the input's pseudo elements. Notice we have two additional properties to complete the reset.

CSS for "hiding the native checkbox input"
input[type="checkbox"] {
  /* Add if not using autoprefixer */
  -webkit-appearance: none;
  appearance: none;
  /* For iOS < 15 to remove gradient background */
  background-color: #fff;
  /* Not removed via appearance */
  margin: 0;
}

Worried about support? This combination of using appearance: none and the ability to style the input's pseudo elements has been supported since 2017 in Chrome, Safari, and Firefox, and in Edge since their switch to Chromium in May 2020.

Step 2: Custom Unchecked Checkbox Styles

For our custom checkbox, we'll update box styles on the base input element. This includes inheriting the font styles to ensure the use of em produces the desired sizing outcome, as well as using currentColor to inherit any update on the label's color.

We use em for the width, height, and border-width value to maintain the relative appearance. We're also customizing the border-radius with another em relative style.

CSS for "custom unchecked checkbox styles"
input[type="checkbox"] {
  appearance: none;
  background-color: #fff;
  margin: 0;
  font: inherit;
  color: currentColor;
  width: 1.15em;
  height: 1.15em;
  border: 0.15em solid currentColor;
  border-radius: 0.15em;
  transform: translateY(-0.075em);
}

.form-control + .form-control {
  margin-top: 1em;
}

Our style updates includes a rule to give some space between our checkboxes by applying margin-top with the help of the adjacent sibling combinator.

Finally, as discussed in our radio button tutorial, we do a small adjustment on the label vs. checkbox alignment using a transform to nudge it up half the width of the border.

Step 3: Styling :checked vs Unchecked State

To prepare for the incoming pseudo element, we first need to change the display behavior of the input to use grid.

input[type="checkbox"] {
  /* ...existing styles */

  display: grid;
  place-content: center;
}

This is the quickest way to align the :before to the horizontal and vertical center of our custom control.

It's now time to bring in our ::before pseudo element which will be styled in order to represent the :checked state. We create the :before element, including a transition and using transform hide it with scale(0):

input[type="checkbox"]::before {
  content: "";
  width: 0.65em;
  height: 0.65em;
  transform: scale(0);
  transition: 120ms transform ease-in-out;
  box-shadow: inset 1em 1em var(--form-control-color);
}

Use of box-shadow instead of background-color will enable the state of the radio to be visible when printed (h/t Alvaro Montoro).

Finally, when the input is :checked, we make it visible with scale(1) with a nicely animated result thanks to the transition. Be sure to change the checkbox state to see the animation!

CSS for ":checked state styles"
input[type="checkbox"] {
  /* ...existing styles */
  display: grid;
  place-content: center;
}

input[type="checkbox"]::before {
  content: "";
  width: 0.65em;
  height: 0.65em;
  transform: scale(0);
  transition: 120ms transform ease-in-out;
  box-shadow: inset 1em 1em var(--form-control-color);
}

input[type="checkbox"]:checked::before {
  transform: scale(1);
}

High Contrast Themes and Forced Colors

As reviewed in the radio buttons tutorial, one more state we need to ensure our radio responds to is what you may hear referred to as "Windows High Contrast Mode" (WHCM). In this mode, the user's operating system swaps out color-related properties for a reduced palette which is an incoming part of the CSS spec called "forced-colors".

Since box-shadow is removed, we'll ensure the :checked state is visible by providing a background-color, which is normally removed in forced-colors mode, but will be retained if we use one of the defined forced colors. In this case, we're selecting CanvasText which will match the regular body text color.

Due to the style stacking order, our box-shadow that we've themed for use in regular mode is actually visuallly placed over the background-color, meaning we can use both without any further modifications.

CSS for "supporting forced-colors"
input[type="checkbox"]::before {
  /* ...existing styles */

  /* Windows High Contrast Mode */
  background-color: CanvasText;
}

Creating the "Checkmark" Shape

Right now, the filled-in state is OK, but it would be ideal to have it shaped as a checkmark to match the more expected pattern.

We have a few options here, such as bringing in an SVG as a background image. However, that solution means losing access to CSS custom properties which we are relying on to "theme" our inputs.

Instead, we'll re-shape the default box by using the clip-path property. This property allows us to treat the pseudo element's box similar to a vector element being reshaped with the pen tool. We define coordinates to redraw the shape between. You can use this handy clip-path generator to draw your own shapes or instantly pick up common ones. We also use clip-path to create a custom select dropdown arrow.

As a matter of preference, I also alter the transform-origin to use a value of bottom left instead of the default of center to mimic a sort of "checking in" animation.

CSS for "creating a checkmark with clip-path"
input[type="checkbox"]::before {
  /* ...existing styles */

  transform-origin: bottom left;
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}

Step 4: The :focus state

In the earlier version of this tutorial, we used box-shadow, but now we have two improved features for the humble outline. First, we can use outline-offset to create a bit of space between the input and the outline. Second, evergreen browsers now support outline following border-radius!

Remember: :focus is a temporary state, but it's very important that it is highly visible to ensure the accessibility of your form controls and other interactive elements.

CSS for ":focus state styles"
input[type="checkbox"]:focus {
  outline: max(2px, 0.15em) solid currentColor;
  outline-offset: max(2px, 0.15em);
}

This concludes our critical styles for the checkbox. If you're interested in an additional method to style the label, check out the radio button tutorial to learn how to use :focus-within.

Styles For :disabled Checkboxes

One step not present in the radio buttons tutorial was styling for the :disabled state.

This will follow a similar pattern as for our previous states, with the change here mostly being to update the color to a grey. We first re-assign the main --form-control-color to the new --form-control-disabled variable. Then, set the color property to use the disabled color.

CSS for ":disabled state styles"
:root {
  --form-control-disabled: #959495;
}

input[type="checkbox"]:disabled {
  --form-control-color: var(--form-control-disabled);

  color: var(--form-control-disabled);
  cursor: not-allowed;
}

We've also updated to set the cursor to not-allowed as an additional visual cue that these inputs are not presently interactive.

But we've hit a snag. Since the label is the parent element, we don't currently have a way in CSS alone to style it based on the :disabled state.

For a CSS-only solution, we need to create an add an extra class to the label when it is known that the checkbox is disabled. Since this state can't be changed by the user, this will generally be an acceptable additional step.

CSS for ":disabled state styles"
.form-control--disabled {
  color: var(--form-control-disabled);
  cursor: not-allowed;
}

Demo

Here's a demo that includes the :disabled styles, and also shows how the power of CSS variables + the use of currentColor means we can re-theme an individual checkbox with a simple inline style. This is very useful for things like a quick change to an error state.

By Stephanie Eckles (@5t3ph)