Custom checkbox component, the right way
Tags: frontend, designsystem, react, css
Published: 2024-04-29 16:34:53

Let’s see how we can implement a custom checkbox component with custom styles.

Rendering a checkbox is simple, all you need is:
```html
<input type="checkbox" />
```
So let's create a component that renders that. 

We'll use React and TypeScript as an example, but what's described here can easily be adapted to other libraries and frameworks.

The first thing that comes to mind is that our component’s API (its props) should be as close as possible to the native `<input type="checkbox"/>`. Everyone knows how to use a native checkbox, so it’ll be easy for anyone to use our component. 

Here’s a first attempt:

```tsx
type CheckboxProps = Omit<HTMLAttributes<HTMLInputElement>, "type">;

export const Checkbox = React.forwardRef(function Checkbox(
  { ...props }: CheckboxProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const { className, ...restProps } = props;
  return (
    <input
      type="checkbox"
      ref={ref}
      // Adding the custom-cb class for styling
      className={`custom-cb ${className}`}
      {...restProps}
    />
  );
});
```

Pretty good, our component renders a checkbox, it can be used anywhere and in the same way a native checkbox can be used (same props). It doesn’t hurt accessibility (provided it’s used in an accessible way) and it’s as flexible. 

We used `forwardRef`, so that when users pass a ref to our component, it’ll be attached to the underlying input. 

Also, note how we are excluding the `type` field from the accepted props using [Omit<>](https://www.typescriptlang.org/docs/handbook/utility-types.html#omittype-keys). Our custom component accepts all the same props as an `<input>` element, except `type`.

Last point before moving on to the next part: For simplicity, we gave our custom checkbox the `custom-cb` class that we’ll use for styling. Whether you use CSS modules, styled-components, TailwindCSS, or any other method for styling, this should be relatively easy to adapt.

Now the hard part: **styling**. 

Styling options for a checkbox are quite limited.  Besides changing the size and the “checked” background color (using the [accent-color](https://developer.mozilla.org/en-US/docs/Web/CSS/accent-color) CSS property, [supported in all major browsers since early 2022](https://caniuse.com/?search=accent-color)), you can’t do much. 

To achieve the desired level of styling, you’ll have to use [appearance: none;](https://developer.mozilla.org/en-US/docs/Web/CSS/appearance) in CSS. 

From there on, nothing gets rendered and you’re on your own: no more borders, no more checkmark nor background color, you have to reimplement EVERYTHING.

So here we go:

```css
.custom-cb {
  appearance: none;
  box-sizing: border-box;
  border: 1px solid darkgrey;
  border-radius: 5px;
  margin: 0;
  width: 24px;
  height: 24px;
}

.custom-cb:disabled {
  opacity: 0.5;
}

.custom-cb:checked {
  background-color: dodgerblue;
  border-color: dodgerblue;
}
```

That’s a good start! We have a square with a border and rounded corners. 


![Gif showing the checkbox with the styles above being checked and unchecked](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ojr50bmwyacuolla95a1.gif)

When the checkbox is disabled, its opacity is reduced.

When the checkbox is “checked”, the square gets filled in blue (and the border turns to the same color). But we’re missing the main part: the checkmark!

## Adding the checkmark

### Using a sibling element

Different solutions can be found online for this, a common one being rendering the checkmark as a sibling of the `<input>`. Something like:

```jsx
export const Checkbox = React.forwardRef(function Checkbox(
  { ...props }: CheckboxProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const { className, ...restProps } = props;
  return (
    <div>
       <input
         type="checkbox"
         ref={ref}
         // Adding the custom-cb class for styling
         className={`custom-cb ${className}`}
         {...restProps}
       />
       <span className="checkmark" /> 
    </div>
  );
});
```

Via some CSS we can easily hide the “.checkmark” `<span>` and show it only when checked. That span could also be an inline `<svg>` or an `<img />` tag, but you get the point. 

The main drawback of this approach is that our component went from rendering 1 element to 2 elements wrapped in a containing `<div>`.

Now, what if you need to add some external margin when using your component? You’d pass a `className` to it but, with the current implementation, it’d be given to the `<input type="checkbox">` instead of the containing `<div>`.

The same thing applies to any style related to <u>positioning</u> the component (things like `display`, `position`, `left` , `top`, `flex`, `align-self` etc…): it needs to be applied to the container (the `<div>` ) and not the `<input />` itself. 

You could give the `className` prop to the wrapping `<div>` instead, but now what if you want to change the background color of the `<input/>` itself? Or the border? 

In other words, styles related to the <u>positioning</u> of the component need to be given to the containing div, while styles related to its <u>appearance</u> need to be given to the input element itself. 

And I’m not even talking about styles that need to be applied to the checkmark element (like changing its color, size or padding). 

You could rely on the cascade, give the class to the wrapping div and ask your users to target the element they need to style (like `.my-cb > input` and `.my-cb > .checkmark` ), but that would expose the internal implementation. 

Or you could expose different `className` props: `containerClassName`, `inputClassName` and `checkMarkClassName` but 1/ that’s still (sort-of) exposing internal implementation and 2/ the API is getting messier.

So let’s try to limit our implementation to a single `<input type="checkbox" />` like before. All the props (including the `className`) are forwarded to that single element and it’s much easier for everyone. 

But how can we display our checkmark then?

### Using a pseudo-element

Let’s use the `::before` pseudo-element (`::after` works too!). My first intuition was to add a `::before` pseudo-element with a checkmark image as background when the checkbox is checked. As for the image itself, we can either serve it from a remote source, or embed it directly using `url()`. Since a SVG of a checkmark is not big, we’ll use the latter and avoid any download-related delay.

Here’s a decent checkmark SVG:

```jsx
<svg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 12'>
  <path stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M1.5 5.917 5.724 10.5 14.5 1.5' />
</svg>

```

We obtain the following `background-image` CSS property:

```css
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 12'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M1.5 5.917 5.724 10.5 14.5 1.5'/%3E%3C/svg%3E")
```

Put together, we obtain the following CSS:

```css
.custom-cb {
  appearance: none;
  box-sizing: border-box;
  border: 1px solid darkgrey;
  border-radius: 5px;
  margin: 0;
  width: 24px;
  height: 24px;
  /* Add some padding so that our checkmark is not to close to the edges */
  padding: 3px;
}

.custom-cb:disabled {
  opacity: 0.5;
}

.custom-cb:checked {
  background-color: dodgerblue;
  border-color: dodgerblue;
}

.custom-cb:checked::before {
  content: " ";
  /* Our checkmark as background image */
  background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 12'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M1.5 5.917 5.724 10.5 14.5 1.5'/%3E%3C/svg%3E");
  /* Centering our checkmark */
  background-position: center center;
  background-repeat: no-repeat;
  /* Fills the entire space */
  width: 100%;
  height: 100%;
  display: block;
}

```


![Gif of the checkbox with the styles above, being checked and unchecked](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4fp8jvgzcwhbz3f3zllb.gif)

That works! We kept the markup the way it was originally, and we managed to get that checkmark displayed.

One issue remains though: what if we want to use different colors for the checkmark? Currently, users can override the background color when checked but they can’t do the same for the checkmark color, since that color is embedded **inside** the SVG (via the `stroke` attribute). That’s not great. 

Tricks involving setting `stroke='inherit'`  or `stroke='currentColor'` in the SVG and setting `stroke` or `color` on the parent with CSS don’t work in this case, since the SVG is embedded in `url()`.

### Masking

Let’s try again, this time using [the `mask` property](https://developer.mozilla.org/en-US/docs/Web/CSS/mask) in lieu of `background-image`. The `mask` property [is supported in all major browsers](https://caniuse.com/css-masks) but it hasn't been for long, so we’ll still use the `-webkit-` vendor prefix just to be safe.

Instead of displaying our SVG directly, we’ll show a `::before` pseudo element that fills the entire checkbox with a uniform background color (the color we want for our checkmark) and we’ll give our SVG to the `mask` CSS property so that only what's inside the shape is displayed. This way, to change the color of our checkmark, we only have to change `background-color` on the `::before` pseudo element.

Here’s the updated CSS:

```css
.custom-cb {
  appearance: none;
  width: 24px;
  height: 24px;
  border: 1px solid darkgrey;
  border-radius: 5px;
  margin: 0;
  box-sizing: border-box;
  /* Add some padding so that our checkmark is not to close to the edges */
  padding: 3px;
}

.custom-cb:disabled {
  opacity: 0.5;
}

.custom-cb:checked {
  background-color: dodgerblue;
  border-color: dodgerblue;
}

.custom-cb:checked::before {
  content: " ";
  /* The color of the checkmark */
  background-color: white;
  /* Fills the entire space... */ 
  width: 100%;
  height: 100%;
  display: block;
  /* ...but only keep what's inside the checkmark shape */
  -webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 12'%3E%3Cpath stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M1.5 5.917 5.724 10.5 14.5 1.5'/%3E%3C/svg%3E")
    no-repeat 50% 50%;
  mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 12'%3E%3Cpath stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M1.5 5.917 5.724 10.5 14.5 1.5'/%3E%3C/svg%3E")
    no-repeat 50% 50%;
}
```

This shows the same result as before, but this time we are able to change the checkmark color with CSS:

```css
.custom-cb:checked::before {
	/* A red checkmark */
  background-color: red;
}
```

## Using CSS variables for better flexibility

As shown above, the required CSS to change the checkmark color involves the `.custom-cb:checked::before` selector. This is not trivial and this is exposing our implementation.

Let’s fix that by adding some CSS variables:

```css
.custom-cb {
  --_checkmark-color: var(--checkmark-color, white);
  appearance: none;
  width: 24px;
  height: 24px;
  border: 1px solid darkgrey;
  border-radius: 5px;
  margin: 0;
  box-sizing: border-box;
  padding: 3px;
}

.custom-cb:disabled {
  opacity: 0.5;
}

.custom-cb:checked {
  background-color: dodgerblue;
  border-color: dodgerblue;
}

.custom-cb:checked::before {
  content: " ";
  /* The color of the checkmark */
  background-color: var(--_checkmark-color);
  /* Fills the entire space... */
  width: 100%;
  height: 100%;
  display: block;
  /* ...but only keep what's inside the checkmark shape */
  -webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 12'%3E%3Cpath stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M1.5 5.917 5.724 10.5 14.5 1.5'/%3E%3C/svg%3E")
    no-repeat 50% 50%;
  mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 12'%3E%3Cpath stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M1.5 5.917 5.724 10.5 14.5 1.5'/%3E%3C/svg%3E")
    no-repeat 50% 50%;
}

```

To change the checkmark color, just use the `--checkmark-color` variable (either on the element itself or any parent). If that variable is not set, we use `white` instead.  

You may wonder about `--_checkmark-color` (with the extra leading underscore). Why not just set `--checkmark-color: white;`at the top, use that value and call it a day? Well, it wouldn’t be possible to use inheritance to set the value. Something like:

```css
body {
	--checkmark-color: red;
}
```

wouldn’t have an effect, since `--checkmark-color` would be reset to white at the input level.

This pattern was described by Lea Verou [here](https://lea.verou.me/blog/2021/10/custom-properties-with-defaults/). 

Now, users can change the checkmark color like below, without having to know *how* the checkmark is implemented:

```css
.custom-cb {
	/* A red checkmark */
	--checkmark-color: red;
}
```

We can apply the same strategy for the background color when checked and we obtain the  following (final) result:

```css
.custom-cb {
  --_checkmark-color: var(--checkmark-color, white);
  --_bg-when-checked: var(--bg-when-checked, dodgerblue);
  appearance: none;
  width: 24px;
  height: 24px;
  border: 1px solid darkgrey;
  border-radius: 5px;
  margin: 0;
  box-sizing: border-box;
  padding: 3px;
}

.custom-cb:disabled {
  opacity: 0.5;
}

.custom-cb:checked {
  background-color: var(--_bg-when-checked);
  border-color: var(--_bg-when-checked);
}

.custom-cb:checked::before {
  content: " ";
  /* The color of the checkmark */
  background-color: var(--_checkmark-color);
  /* Fills the entire space... */
  width: 100%;
  height: 100%;
  display: block;
  /* ...but only keep what's inside the checkmark shape */
  -webkit-mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 12'%3E%3Cpath stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M1.5 5.917 5.724 10.5 14.5 1.5'/%3E%3C/svg%3E")
    no-repeat 50% 50%;
  mask: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 12'%3E%3Cpath stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M1.5 5.917 5.724 10.5 14.5 1.5'/%3E%3C/svg%3E")
    no-repeat 50% 50%;
}
```

Now we have a custom checkbox component that is to be used exactly in the same way as a regular `<input type="checkbox" />` , with good default styles that are also flexible: users can now update the size, border, background color when checked and unchecked, as well as the checkmark color.

## Should the checkbox component include a label?

I’ve seen a few custom checkbox implementations over the years, and a good portion of them came with the label. Something like this:

```jsx
export const Checkbox = React.forwardRef(function Checkbox(
  { ...props }: CheckboxProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const { className, label, ...restProps } = props;
  return (
    <label>
       {label}
       <input
         type="checkbox"
         ref={ref}
         // Adding the custom-cb class for styling
         className={`custom-cb ${className}`}
         {...restProps}
       />
    </label>
  );
});
```

This may seem like a good idea at first but this comes with a few challenges:

- What if you need the label to be on the right instead of the left? Or above it? Sure, you can add a `labelPosition` prop, but are you sure you’ll capture all the possibilities? What if there is no label and you want to use `aria-label` instead? You should probably not include a `<label>` element at all in that case.
- What if the checkbox is part of a grid or flex layout and you need the `<input>` and `<label>` to be siblings (linked together using the `for` attribute)?
- Similarly to what was described above in the *sibling element* paragraph, our component now renders multiple elements wrapped in a label, which makes things difficult when it comes to styling.

Let’s not do that. 

In HTML, a checkbox can receive a label in different ways and there is no constraint as to *how* that label is positioned in regards to the checkbox. Trying to capture the different possibilities in an API will likely result in a messy and confusing API. And it is only a matter of time before you have to implement a design that your custom checkbox does not support. Then you’ll be faced with 2 options, each of them leading to a mess down the line: 

1. Add a prop to the checkbox component to account for the new case,
2. Or not use the checkbox component for this case and create a one-off checkbox.

## Demo

Here is a demo of our completed component:

{% codepen https://codepen.io/jdelort/embed/WNWWYGW default-tab=result %}

## Conclusion

Feel free to copy and paste the code shown here in your own codebase. As I mentioned in the beginning, since our component only renders an `<input type="checkbox" />` you might not need to create a component at all, a CSS class may suffice depending on your tech stack.

Here are a few takeaways from this article:
1.  If you do create a custom checkbox component, keep its API as close as possible to the native checkbox API. It should accept all the same props, events etc... (Except for the `type` attribute).
2. Try to keep your checkbox implementation as a single `<input type="checkbox" />` component. As soon as you introduce a container, siblings etc... you'll either loose in styling flexibility or the props will be complicated.
3. Similarly, don't include a `label` prop in your checkbox component. Let users add the label the same way they would do with a regular `<input type="checkbox" />`.