State-of-the-art frontend development
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.
(You can find the video version of this article on YouTube)
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
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...
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>
);
}
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;
}
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:
The native checkbox is hidden but we still need to style our custom one.
Let's do it in the next section!
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;
}
This is going to result in something like this:
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>
);
}
(You can find the source code for this section on CodeSandbox)
In this section, we'll make the checkbox even prettier while maintaining its accessibility.
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>
);
}
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>
);
}
Finally, we'll remove the checkbox--active
class from our CSS file as it's no longer needed.
We'll be using a famous technique for animating SVG Paths (using
strokeDashoffset
andstrokeDasharray
) 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>
);
}
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;
If you now try your code out, you'll see that it's working quite okay!
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,
});
// ...
}
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
);
// ...
}
If we now play the animation, it looks bonkers!
You can find the source code for the whole tutorial on CodeSandbox
Stay up to date with state-of-the-art frontend development.