DEV Community

Tom Dohnal
Tom Dohnal

Posted on

Custom Checkbox in React (Animated and Accessible)

In this article, you're going to learn how to create a custom animated (yet accessible) checkbox. The technique that you'll learn is also applicable for styling radio inputs.

Kapture 2021-10-05 at 22.30.00

(You can find the video version of this article on YouTube)

How NOT to create a custom checkbox?

Let's first have a look at some possible (but wrong) approaches to creating custom checkboxes and explore their drawbacks.

As you can't really use CSS (as you'd normally do for styling form elements like buttons or text inputs) to style your checkboxes, you might be tempted to do something like this:

// ❌ Do NOT do this. (Bad a11y + hard to integrate with form libraries)
function Checkbox() {
  const [isChecked, setIsChecked] = useState(false)

  return (
    <div
      className={`checkbox ${isChecked ? 'checkbox--active' : ''}`}
      onClick={() => { setIsChecked(!isChecked) }}
    />
  )
}

// + provide styles for .checkbox .checkbox--active classes
Enter fullscreen mode Exit fullscreen mode

There are several problems with this approach.

1) It's bad for for the accessibility
If your user happens to be using a screen reader, there is no way that the screen reader can recognise that your div is actually a checkbox (let alone recognise if the checkbox is checked or not).

2) It breaks the native form behaviour
The div element doesn't emit change events so it's harder to integrate it with form libraries. Moreover, the "form data" on the div element aren't sent to the server upon form submission.

You could fix this by using aria-role="checkbox" on the div element, other aria-* attributes and a lot of JavaScript.

However, there is a simpler way...

How to create a custom checkbox?

First, we'll have a look at how we'll approach it conceptually so that we have a "big picture" of the implementation.

We're going to use three different HTML elements for creating a custom checkbox. A label, an input[type="checkbox"], and span (or svg or whatever you'd like 😉).

The input[type"checkbox"] is going to be visually hidden (but still accessible for screen readers), and we're gonna use the label element as a parent element so that clicking anywhere in the checkbox triggers the change event on the input[type="checkbox"].

Using label as a parent element is valid HTML as per https://www.w3.org/TR/html401/interact/forms.html#edef-LABEL

We'll use aria-hidden="true" on the custom (span or svg) checkbox so that it's hidden for screen readers as its purpose is only "decorative". We're also going to toggle checkbox--active class on it so that we can style it differently for "checked" and "unchecked" states.

With that said, let's write some JSX

import { useState } from "react";

function Checkbox() {
  const [isChecked, setIsChecked] = useState(false);

  return (
    <label>
      <input
        type="checkbox"
        onChange={() => {
          setIsChecked(!isChecked);
        }}
      />
      <span
        className={`checkbox ${isChecked ? "checkbox--active" : ""}`}
        // This element is purely decorative so
        // we hide it for screen readers
        aria-hidden="true"
      />
      Don't you dare to check me!
    </label>
  );
}
Enter fullscreen mode Exit fullscreen mode

To visually hide the native checkbox, create (and import) a new CSS file with the following:

/* taken from https://css-tricks.com/inclusively-hidden/ */
input[type="checkbox"] {
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  height: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
  width: 1px;
}
Enter fullscreen mode Exit fullscreen mode

To learn more about visually hiding things in CSS, visit this blog post on CSS Tricks

If you now hit save and open the browser, you'll see something like this:
Screenshot 2021-10-05 at 00.12.14

The native checkbox is hidden but we still need to style our custom one.

Let's do it in the next section!

Styling Our Custom Checkbox

Let's first include some styles for our custom checkbox:

.checkbox {
  display: inline-block; // set to `inline-block` as `inline elements ignore `height` and `width`
  height: 20px;
  width: 20px;
  background: #fff;
  border: 2px #ddd solid;
  margin-right: 4px;
}

.checkbox--active {
  border-color: purple;
  background: purple;
}
Enter fullscreen mode Exit fullscreen mode

This is going to result in something like this:
Kapture 2021-10-05 at 00.34.41

While it reacts to our input, it's missing something–a checkmark indicating if the checkbox is checked or not. Let's turn our span into an svg and add a checkmark.

// ...

function Checkbox() {
  // ...

  return (
    <label>
      {/* ... */}
      <svg
        className={`checkbox ${isChecked ? "checkbox--active" : ""}`}
        // This element is purely decorative so
        // we hide it for screen readers
        aria-hidden="true"
        viewBox="0 0 15 11"
        fill="none"
      >
        <path
          d="M1 4.5L5 9L14 1"
          strokeWidth="2"
          stroke={isChecked ? "#fff" : "none"} // only show the checkmark when `isCheck` is `true`
        />
      </svg>
      Don't you dare to check me!
    </label>
  );
}
Enter fullscreen mode Exit fullscreen mode

(You can find the source code for this section on CodeSandbox)

Animating Checkbox using React Spring

In this section, we'll make the checkbox even prettier while maintaining its accessibility.

Kapture 2021-10-05 at 22.30.00

We'll be using React Spring library for the animations. You might be able to pull this animation off just with plain CSS but as we'll be animating the SVG Path and we'll need JavaScript to measure its length to make the animation possible, library like React Spring will come in handy.

I've created a whole series on animating SVGs with React Spring. Be sure to check it out if you want to learn about it in more detail!

First, let's tackle the easier bit–animating the background and border colours.

After adding react-spring as a dependency using your favourite package manager, let's import animated and useSpring from the library and turn svg into animated.svg and path into animated.path so that they're set and ready to be animated.

// ...
import { animated, useSpring } from "react-spring";

function Checkbox() {
  return (
    <label>
      {/* ... */}
      <animated.svg /* ... */>
        <animated.path /* ... *//>
      </animated.svg>
      {/* ... */}
    </label>
  );
}
Enter fullscreen mode Exit fullscreen mode

Once we're done, we'll use the useSpring hook to animate backgroundColor and borderColor attributes. This is going to be analogical to toggling the values of those properties by using the checkbox--active CSS class.

// ...

function Checkbox() {
  // ...
  const checkboxAnimationStyle = useSpring({
    backgroundColor: isChecked ? "#808" : "#fff",
    borderColor: isChecked ? "#808" : "#ddd"
  });

  return (
    <label>
      {/* ... */}
      <animated.svg
        style={checkboxAnimationStyle}
        /* ... */
      >
        {/* ... */}
      </animated.svg>
      {/* ... */}
    </label>
  );
}
Enter fullscreen mode Exit fullscreen mode

Finally, we'll remove the checkbox--active class from our CSS file as it's no longer needed.

Animating the Checkmark

We'll be using a famous technique for animating SVG Paths (using strokeDashoffset and strokeDasharray) in this tutorial. I created a whole blog post explaining this subject in much more detail.

To animate the checkmark, we first need to measure (and store) its length. We'll use useState(...) to store its length, pass a callback to the ref property of our SVG Path, and call ref.getTotalLength() to measure its length.

// ...

function Checkbox() {
  // ...
  const [checkmarkLength, setCheckmarkLength] = useState(null);

  return (
    <label>
      {/* ... */}
      <animated.svg /* ... */>
        <animated.path
          {/* ... */}
          ref={(ref) => {
            if (ref) {
              setCheckmarkLength(ref.getTotalLength());
            }
          }}
        />
      </animated.svg>
      {/* ... */}
    </label>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now that we've got the length of the path, we can set the strokeDasharray to checkmarkLength and use useSpring to animate the strokeDashoffset between 0 and checkmarkLength. And we'll set the stroke to #fff no matter of the isActive state value.

If you're not use how this works, see my blog post where I explain this very technique in detail.

// ...

function Checkbox() {
  // ...

  const checkmarkAnimationStyle = useSpring({
    x: isChecked ? 0 : checkmarkLength
  });

  return (
    <label>
      {/* ... */}
      <animated.svg /* ... */>
        <animated.path
          // ...
          stroke="#fff"
          strokeDasharray={checkmarkLength}
          strokeDashoffset={checkmarkAnimationStyle.x}
        />
      </animated.svg>
      Don't you dare to check me!
    </label>
  );
}

export default Checkbox;
Enter fullscreen mode Exit fullscreen mode

If you now try your code out, you'll see that it's working quite okay!
Kapture 2021-10-05 at 22.31.34

While our animation is working quite smoothly, I think we can still add a little bit of spice to take it to the next level.

First, let's tweak the config of the useSpring hook. Let's import the config variable from React Spring which includes some predefined configs and use config: config.gentle in our useSpring(...) calls. This is going to give our animations a little bit more of a playful feel.

// ...
import { /* ... */ config } from "react-spring";

function Checkbox() {
  // ...
  const checkboxAnimationStyle = useSpring({
    // ...
    config: config.gentle
  });

  // ...

  const checkmarkAnimationStyle = useSpring({
    // ...
    config: config.gentle
  });

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Next, if you look at the animation really closely, you'll notice that the checkmark animation only appears for a brief moment. That's because the checkmark is white for the whole duration of the animation while the background is animating from white to purple. So during the time when the background is white, the checkmark is barely visible (as it's white on a white background).

We can tackle this by using the useChain hook from React Spring. This hook enables us to trigger the useSpring(...) animations one after another. In our case, we'll use it to delay the checkmark animation a bit so that it only starts animating when the background of the checkbox is already mostly purple. We'll do the opposite when animating in the other direction.

Let's import useChain along with useSpringRef from react-spring. Then, we'll use the useSpringRef hook to create references to our useSpring calls which we'll then pass into the useChain function:

// ...
import {
  // ...
  useSpringRef,
  useChain
} from "react-spring";

function Checkbox() {
  // ...
  const checkboxAnimationRef = useSpringRef();
  const checkboxAnimationStyle = useSpring({
    // ...
    ref: checkboxAnimationRef
  });

  // ...

  const checkmarkAnimationRef = useSpringRef();
  const checkmarkAnimationStyle = useSpring({
    // ...
    ref: checkmarkAnimationRef
  });

  useChain(
    isChecked
      ? [checkboxAnimationRef, checkmarkAnimationRef]
      : [checkmarkAnimationRef, checkboxAnimationRef],
    [0, 0.1] // -> delay by 0.1 seconds
  );

  // ...
}
Enter fullscreen mode Exit fullscreen mode

If we now play the animation, it looks bonkers!
Kapture 2021-10-05 at 22.30.00

You can find the source code for the whole tutorial on CodeSandbox

Discussion (0)