DEV Community

Cover image for Custom checkbox component, the right way
Julien Delort
Julien Delort

Posted on

Custom checkbox component, the right way

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

Rendering a checkbox is simple, all you need is:

<input type="checkbox" />
Enter fullscreen mode Exit fullscreen mode

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:

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}
    />
  );
});
Enter fullscreen mode Exit fullscreen mode

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<>. 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 CSS property, supported in all major browsers since early 2022), you can’t do much.

To achieve the desired level of styling, you’ll have to use appearance: none; 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:

.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;
}
Enter fullscreen mode Exit fullscreen mode

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

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:

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>
  );
});
Enter fullscreen mode Exit fullscreen mode

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 positioning 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 positioning of the component need to be given to the containing div, while styles related to its appearance 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:

<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>

Enter fullscreen mode Exit fullscreen mode

We obtain the following background-image CSS property:

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")
Enter fullscreen mode Exit fullscreen mode

Put together, we obtain the following 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;
}

Enter fullscreen mode Exit fullscreen mode

Gif of the checkbox with the styles above, being checked and unchecked

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 in lieu of background-image. The mask property is supported in all major browsers 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:

.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%;
}
Enter fullscreen mode Exit fullscreen mode

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

.custom-cb:checked::before {
    /* A red checkmark */
  background-color: red;
}
Enter fullscreen mode Exit fullscreen mode

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:

.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%;
}

Enter fullscreen mode Exit fullscreen mode

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:

body {
    --checkmark-color: red;
}
Enter fullscreen mode Exit fullscreen mode

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.

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

.custom-cb {
    /* A red checkmark */
    --checkmark-color: red;
}
Enter fullscreen mode Exit fullscreen mode

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

.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%;
}
Enter fullscreen mode Exit fullscreen mode

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:

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>
  );
});
Enter fullscreen mode Exit fullscreen mode

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:

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" />.

Top comments (0)