In this guide, we will cover how to build a custom checkbox in React without sacrificing accessibility for assistive technology:
Below is the custom checkbox we will build in this tutorial. You can interact with it, and after that, get started.
To follow this tutorial, ensure you are familiar with React.
The checkbox is a control element that provides an option to toggle between true and false states. It is an input
element with the type
attribute set to a checkbox
like so:
<input type="checkbox" />
In its simplest form, an input checkbox alongside some label text can look like this:
Input checkboxes have default styles applied to them. These styles come from the browser’s underlying operating system, thus making the checkbox appearance vary across various browsers.
The first example labeled in the image above shows how the design of the checkbox would appear in the Opera mini browser. The second example is how the same default checkbox would appear in Firefox, which also has a similar appearance in Chrome.
Generally, one reason we might want to have a custom checkbox on a web page is to ensure the appearance is consistent across browsers and devices.
Handling form controls in React is a bit different from handling them in regular HTML.
In HTML, form controls — including checkboxes — listen to and are managed by the DOM. In that case, we don’t need to set up any logic to toggle the boxes; they can be toggled by default.
However, in React, the checkbox must either be a controlled or uncontrolled input. A controlled input allows us to take the responsibility away from the browser DOM and hand it over to a component state (usually, the component that is rendering the inputs).
React recommends this approach to form controls. This way, the state becomes the only source of truth. Let’s see this in practice!
Let’s start by creating a component called Checkbox
that accepts a label
text as a prop and renders a checkbox alongside the text:
const Checkbox = ({ label }) => { return ( <div className="checkbox-wrapper"> <label> <input type="checkbox" /> <span>{label}</span> </label> </div> ); }; export default Checkbox;
Then we will render the <Checkbox />
component wherever we want to display a checkbox. In our case, we will render it in an App
component:
import Checkbox from "./Checkbox"; const App = () => { return ( <div className="app"> <Checkbox label="Subscribe to newsletter?" /> </div> ); }; export default App;
These steps should result in something like this:
Note that we have included basic styles that added a border for the containing elements. At this point, we haven’t done anything special in rendering the checkbox. The above is similar to rendering a basic HTML checkbox.
As mentioned earlier, React recommends making our form elements a controlled field. To do this, we must add a component state to manage the user’s input and then pass the state variable to the input. For checkbox input, we will assign the state to the input checked
attribute:
import { useState } from "react"; const Checkbox = ({ label }) => { const [isChecked, setIsChecked] = useState(false); return ( <div className="checkbox-wrapper"> <label> <input type="checkbox" checked={isChecked} /> <span>{label}</span> </label> </div> ); }; export default Checkbox;
With the above implementation, the checkbox will no longer listen to the DOM, but rather the useState
defined in the component. Since we initialized the useState
with a false
value, the checkbox field will be unchecked on the initial DOM render.
Later on this page, we will define logic that lets us pass the initial state into the Checkbox
component, whether we want it checked, unchecked, or disabled.
At this point, we have succeeded in making the checkbox a controlled input. However, we cannot toggle it because it no longer listens to the browser DOM. So we need to handle the toggling ourselves.
The updater function is the second parameter of the useState()
React Hook. It allows us to update the state and ensure it holds the updated state value.
To use the updater function, we must listen to a change in the input checkbox using an onChange
event handler. This handler will call the updater function setIsChecked
for every input change with the latest input value:
const Checkbox = ({ label }) => { // ... return ( <div className="checkbox-wrapper"> <label> <input // ... onChange={() => setIsChecked((prev) => !prev)} /> {/* ... */} </label> <p>{isChecked ? "Selected" : "Unchecked"}</p> </div> ); }; export default Checkbox;
As seen in the code, the setIsChecked
updater function takes the previous state value and updates it by toggling its Boolean value.
Notice that we added a paragraph after the label to show that the checkbox listens to the state defined in the component:
Let’s provide an option to pass some props alongside the label
into the Checkbox
component. For instance, we can specify whether or not a checkbox should be checked by default, and whether the checkbox is disabled or not.
In the App
component below, we’ve added additional <Checkbox />
to include a checked
and disabled
prop:
const App = () => { return ( <div className="app"> <Checkbox label="Subscribe to newsletter?" checked={true} /> <Checkbox label="I agree to terms & conditions" checked={true} disabled /> <Checkbox label="Save form inputs to storage?" /> </div> ); }; export default App;
Assigning a true
value to the checked
prop means we want the checkbox field checked by default. Otherwise, we want it unchecked. Likewise, passing a disabled
prop means we want the checkbox disabled.
Next, let’s access the props from the Checkbox
component and use them:
const Checkbox = ({ label, checked, ...props }) => { const defaultChecked = checked ? checked : false; const [isChecked, setIsChecked] = useState(defaultChecked); return ( <div className="checkbox-wrapper"> <label> <input // ... {...props} /> {/* ... */} </div> ); }; export default Checkbox;
In the code, we started by using the ternary operator to check if the value of the checked
prop is true
or false
. The result is then used as the initial state value that gets applied to the checkbox on the initial DOM render.
Then, on the input
element, we spread the other props that the component receives. For instance, the component may receive a disabled
prop, so it also gets applied to the input element. At this point, our checkboxes look like this:
The first and second checkboxes are checked by default because we assigned a true
value to their checked
prop. The second checkbox is disabled because we also passed along a disabled
prop. In the next section, we will give our checkboxes a custom look and feel.
We mentioned earlier that the styles applied to the native checkbox are inherited from the browser’s operating system. This results in some styling restrictions on the checkbox.
There are various ways to style React components. One way we can overcome these specific restrictions and provide a custom design for our checkbox is to remove the default appearance using the appearance: none;
CSS property.
Another method — which is more of a hack — is to hide the native checkbox and then create a custom design while still listening to events on the hidden checkbox.
For this second method, we must not set the CSS display property to none
or use the hidden
attribute so that the checkboxes are not invisible to the browsers and screen readers.
Instead, we can simply apply opacity: 0;
and position: absolute;
to hide the native checkbox. Then, we create a pseudo-element on the label
element that we can style to act in place of the checkbox.
The custom checkbox in this tutorial is built on the native checkbox in React, which naturally provides support for assistive technology. We can strip off the browser styles using the CSS property appearance: none;
and give it a custom look, which we will cover in more detail below.
Using this method or the other one using opacity: 0;
and position: absolute;
that we mentioned earlier, the checkbox will naturally support assistive technology and can be focusable and keyboard-accessible.
If we had set the CSS display
property of the input checkbox to none
or used the hidden
attribute on the input
, the checkbox would be invisible to the browsers. This does not support web accessibility, so we should avoid doing that.
Another way we can customize a checkbox is to replace the native input
with a non-focusable element like <div>
or span
and then style it to take the shape of a checkbox. In that case, we must repurpose the element by adding ARIA attributes like role="checkbox"
, aria-checked
, tabindex="0"
to support assistive technology.
However, it is better to build a custom checkbox on the native input. Keep reading to see how we have done so in this tutorial using the appearance: none;
CSS property.
appearance: none;
CSS propertyThe approach using appearance: none;
is modern and suitable for customizing the checkbox. We will start by removing the default styles of the native checkbox and adding custom styles:
.checkbox-wrapper input[type="checkbox"] { /* removing default appearance */ -webkit-appearance: none; appearance: none; /* creating a custom design */ width: 1.6em; height: 1.6em; border-radius: 0.15em; margin-right: 0.5em; border: 0.15em solid #007a7e; outline: none; cursor: pointer; }
The focus here is on the appearance
property, which we set to none
. This lets us create a custom design for the checkbox. The checkboxes should now look like this:
Next, we need to style the custom box when the field is checked. We can do this by dynamically adding a custom class to the input element, which we can then style. We can also use the :checked
pseudo-class selector to match every checked box.
We will use the ternary operator to add a custom class to the input if the isChecked
state variable is true
:
const Checkbox = ({ label, checked, ...props }) => { // ... return ( <div className="checkbox-wrapper"> <label> <input // ... className={isChecked ? "checked" : ""} /> </label> </div> ); }; export default Checkbox;
Then we can style the selected input field with CSS using the dynamic class like so:
input.checked { background-color: #007a7e; position: relative; }
Before we take a look at the design, let’s add a check mark to indicate that the field is checked. To do this, we will use the CSS content
property with the ::before
pseudo-element to insert the check mark.
Our updated CSS file should have the following:
input.checked::before { content: "2714"; font-size: 1.5em; color: #fff; position: absolute; right: 1px; top: -5px; }
Next, let’s add the following styles for the disabled
and focus
states:
.checkbox-wrapper input[type="checkbox"]:disabled { border-color: #c0c0c0; background-color: #c0c0c0; } .checkbox-wrapper input[type="checkbox"]:disabled + span { color: #c0c0c0; } .checkbox-wrapper input[type="checkbox"]:focus { box-shadow: 0 0 20px #007a7e; }
The checkboxes should now look like so:
:checked
selector instead of adding a custom classIf we don’t want to add a custom class to the input as we did above, we can use the :checked
selector on the input to target the checked element and then style it accordingly. In this case, we can remove the className
attribute from the input
element so it looks like this:
<input type="checkbox" checked={isChecked} onChange={() => setIsChecked((prev) => !prev)} {...props} />
Then, we replace the .checked
class selector with the :checked
pseudo-class selector:
input:checked { background-color: #007a7e; position: relative; } input:checked::before { content: "2714"; font-size: 1.5em; color: #fff; position: absolute; right: 1px; top: -5px; }
At this point, we can save all files and test our project. See the demo and complete code on CodeSandbox.
Sometimes, we may want to have the input
and label
elements as siblings instead of nesting the input
directly inside the label
. In this case, we must make provision for a unique id
to associate every label
with their input
element.
For this, each of the <Checkbox />
elements must include a unique id
prop like so:
import Checkbox from "./Checkbox"; const App = () => { return ( <div className="app"> <Checkbox id="subscribeNews" label="Subscribe to newsletter?" checked={true} /> {/* other checkboxes */} </div> ); } export default App;
Then in the Checkbox
component, we can access the id
and use it to associate the input element with the label, like so:
const Checkbox = ({ id, label, checked, ...props }) => { // ... return ( <div className="checkbox-wrapper"> <input id={id} type="checkbox" checked={isChecked} onChange={() => setIsChecked((prev) => !prev)} {...props} /> <label htmlFor={id}>{label}</label> </div> ); } export default Checkbox;
There are some unusual cases where you must handle and process the state of multiple checkboxes. For example, a simple to-do app that requires the user to change the state of each task by toggling their respective checkboxes. Here’s how we can put something like this into action:
Of course, we’ll need an array of to-dos first, so let’s create one with the useState
hook:
import { useState } from "react"; const App = () => { const [todos, setTodos] = useState([ { task: "Pick up groceries", done: false }, { task: "Buy Milk", done: true }, { task: "Complete Project X", done: false }, ]); return ( <div className="app"> {/* markup here */} </div> ); }; export default App;
Then, in our returned JSX, we can loop through our todos
and render a checkbox for each iteration, as shown below:
<div className="app"> <h1>My Tasks</h1> {todos.map(({ task, done }, i) => ( <div key={i}> <label htmlFor={i}> <input type="checkbox" onChange={() => handleChange(done, i)} checked={done} id={i} /> <span>{task}</span> </label> </div> ))} </div>
You’ll notice we added a new handleChange()
function for when the value of each checkbox changes — passing the state of the task and its index in our to-dos array; we also need to define this function as below:
const handleChange = (done, i) => { let tmp = todos[i]; tmp.done = !done; let todosClone = [...todos]; todosClone[i] = tmp; setTodos([...todosClone]); };
The goal of this function is to change the status of the passed to-do item. To accomplish this operation, we first created temporary variables that store the to-do items’ new state. We then modified our original todos
array and set the value of the task with the passed index to the one generated earlier.
As a result, all of our checkboxes are now reactive, and if we toggle any of their states, the values change instantly. Finally, I’d like us to display the total length of all completed tasks to do this, so let’s replace the returned JSX code with the following:
<div className="app"> <h1>My Tasks</h1> {todos.map(({ task, done }, i) => ( <div key={i}> <label htmlFor={i}> <input type="checkbox" onChange={() => handleChange(done, i)} checked={done} id={i} /> <span>{task}</span> </label> </div> ))} <h3>Total Completed: {todos.filter((todo) => todo.done).length}</h3> </div>
The new addition here is that we added code that filters through our todos
array and returns the total number of tasks that have been marked as completed. If we preview our application at this point, we should have the following output:
Building a custom checkbox is ideal if we want to maintain a consistent appearance across browsers and devices. In HTML, we only need to customize the native checkbox. React, however, wants us to control the checkboxes so they are in sync with the component state.
In this tutorial, we learned how to work with form control in React while also building a custom checkbox. If you enjoyed reading this tutorial, endeavor to share it around the web. And if you have questions and or contributions, share your thoughts in the comment section.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]