Native HTML checkboxes look different accross browsers and don't allow for much customisation, which can be frustrating. In this article we'll look at how we can use pseudo elements to style checkboxes—I'll use a React example in order to add validation but this trick works in pure HTML and CSS so there is no JavaScript needed!
Before we dive in, I want to touch on the accent-color
property. It allows us to set the accent colour for form controls, including radio buttons and checkboxes. In most situations, this property is enough (I would also recommending setting the checkbox size to a relative value so it scales with the font size). The rest of this tutorial will focus on a more complex solution, which is only required if you want to use a custom checkbox shape, including images.
styles.css
input[type="checkbox"] {
accent-color: var(--primary);
width: 1.2em;
height: 1.2em;
}
Markup
Before we add interaction to the elements, let's look at the skeleton of our form.
Form.jsx
const Form = () => {
return (
<form>
<fieldset
// Let screen readers know when field is invalid
aria-invalid="false"
// And where to look for the error
aria-describedby="hobbies-error"
>
<legend>Hobbies</legend>
<label>
Sleeping
<input
type="checkbox"
name="hobbies"
value="Sleeping"
/>
<span className="checkmark" />
</label>
<label>
Coding
<input
type="checkbox"
name="hobbies"
value="Coding"
/>
<span className="checkmark" />
</label>
{/* This is where our error is going to go. We add role="alert" to the element so screen readers can announce it. */}
<p className="error" id="hobbies-error" role="alert" aria-atomic="true">
</p>
</fieldset>
<input type="submit" value="Submit" />
</form>
);
};
Styles
Now we can hide the default checkbox and add our fake checkbox. Since the fake one is a sibling of the input
, we can use the ~
selector to style the checkbox based on the input state.
checkbox.scss
label {
// We need position: relative on the label
// to be able to position the checkbox
position: relative;
display: block;
font-size: 1.6rem;
// Since our checkbox width is 1.2em,
// the line-height and the left padding
// need to be at least 1.2em
// We'll go for more to give it some breathing space
line-height: 1.6;
padding: 0 0 0 2em;
// Since our input is inside the label, users
// can click on the whole label to tick the checkbox
// this is great for accessibility!
cursor: pointer;
user-select: none;
// Spacing between the checkboxes
& + label {
margin-top: 8px;
}
// Hide the default input
input {
position: absolute;
opacity: 0;
height: 0;
width: 0;
cursor: pointer;
// When the input is checked, update the
// background and colour of the fake checkbox
&:checked ~ .checkmark {
background-color: $text;
&:after {
opacity: 1;
border-color: $white;
}
}
// Change the checkmark opacity on focus
&:focus-visible ~ .checkmark::after {
opacity: 0.5;
}
}
// Change the checkmark opacity when hovering
// over the whole label
&:hover input ~ .checkmark::after {
opacity: 0.5;
}
}
.checkmark {
position: absolute;
top: 0;
left: 0;
height: 1.2em;
width: 1.2em;
border: 0.15em solid rgba(94, 82, 82, 0.7);
border-radius: 4px;
&:after {
content: "";
position: absolute;
display: block;
opacity: 0;
// We use relative values to support Zoom
left: 0.35em;
top: 0.05em;
width: 0.3em;
height: 0.7em;
border-color: $text;
border-style: solid;
// We create the checkmark using two invisible borders
border-width: 0 0.15em 0.15em 0;
transform: rotate(45deg);
}
}
Interaction
Let's say we have an array of four hobbies:
hobbies.ts
const hobbies = ["Hiking", "Reading", "Sleeping", "Coding"];
We can use map()
to render our checkboxes:
Form.tsx
export default function Form() {
// We create an array of boolean values
// the same length as the hobbies array
// [false, false, false, false]
const [checkboxState, setCheckboxState] = useState(
new Array(hobbies.length).fill(false)
);
const getSelectedHobbies = (selectedState: boolean[] = checkboxState) => {
return hobbies.filter((hobby, hobbyIndex) => selectedState[hobbyIndex]);
};
let selectedHobbies = getSelectedHobbies();
const onFieldChange = async (checkboxIndex: number) => {
const updatedCheckedState = checkboxState.map((checked, index) =>
index === checkboxIndex ? !checked : checked
);
setCheckboxState(updatedCheckedState);
};
return (
<form>
<fieldset
aria-invalid="false"
aria-describedby="hobbies-error"
>
<legend>Hobbies</legend>
{hobbies.map((hobby, hobbyIndex) => (
<Checkbox
key={hobby}
value={hobby}
onChange={() => onFieldChange(hobbyIndex)}
/>
))}
<p className="error" id="hobbies-error" role="alert" aria-atomic="true">
{checkboxError}
</p>
</fieldset>
</form>
);
}
Checkbox.tsx
import type { ChangeEventHandler } from "react";
type CheckboxProps = {
onChange: ChangeEventHandler<HTMLInputElement>;
value: string;
};
const Checkbox = ({ onChange, value }: CheckboxProps) => {
return (
<>
<label>
{value}
<input
type="checkbox"
name="hobbies"
value={value}
onChange={onChange}
/>
<span className="checkmark" />
</label>
</>
);
};
export default Checkbox;
Before we add a submit function to our form
, let's make the field required and add some validation. We're going to use yup
, so let's run npm install yup
and create a FormValidationSchema
file.
FormValidationSchema.tsx
import { array, object, string } from "yup";
export const formSchema = object({
hobbies: array()
.of(
string().oneOf(
["Hiking", "Reading", "Sleeping", "Coding"],
"Please select one of the available options."
)
)
.min(1, "Please select at least one hobby.")
.required("Please select at least one hobby.")
});
We can import our schema in Form.tsx
and check if our fields are valid before submitting the form:
Form.tsx
export default function Form() {
const [checkboxState, setCheckboxState] = useState(
new Array(hobbies.length).fill(false)
);
const [checkboxError, setCheckboxError] = useState<string>("");
const getSelectedHobbies = (selectedState: boolean[] = checkboxState) => {
return hobbies.filter((hobby, hobbyIndex) => selectedState[hobbyIndex]);
};
let selectedHobbies = getSelectedHobbies();
const submitForm = async (e: FormEvent) => {
e.preventDefault();
const isFormValid = await formSchema.isValid(
{ hobbies: selectedHobbies },
{
abortEarly: false
}
);
if (isFormValid) {
alert("Success!");
} else {
// If the form is invalid, check which fields are incorrect
// In our case, it can only be the "hobbies" field
formSchema
.validate({ hobbies: selectedHobbies }, { abortEarly: false })
.catch((err) => {
const errors = err.inner.reduce(
(acc: any, error: { path: any; message: any }) => {
return {
...acc,
[error.path]: error.message
};
},
{}
);
if (errors.hobbies) {
setCheckboxError(errors.hobbies);
}
});
}
};
return (
<form onSubmit={submitForm} noValidate>
{* ... */}
</form>
);
}
We can improve the validation UX even more and remove the error as soon as the user selects an option by updating the onFieldChange()
function:
Form.tsx
const onFieldChange = async (checkboxIndex: number) => {
const updatedCheckedState = checkboxState.map((checked, index) =>
index === checkboxIndex ? !checked : checked
);
setCheckboxState(updatedCheckedState);
// Check if the form is valid now that the values have changed
const isFormValid = await formSchema.isValid(
{ hobbies: getSelectedHobbies(updatedCheckedState) },
{
abortEarly: false, // Prevent aborting validation after first error
}
);
// Remove checkbox error if needed
if (checkboxError && isFormValid) {
setCheckboxError("");
}
};
Demo
import { useState } from "react"; import Checkbox from "./components/Checkbox"; import { formSchema } from "./validation/FormValidationSchema"; import type { FormEvent } from "react"; import "./styles/styles.scss"; const hobbies = ["Hiking", "Reading", "Sleeping", "Coding"]; export default function Form() { const [checkboxState, setCheckboxState] = useState( new Array(hobbies.length).fill(false) ); const [checkboxError, setCheckboxError] = useState<string>(""); const getSelectedHobbies = (selectedState: boolean[] = checkboxState) => { return hobbies.filter((hobby, hobbyIndex) => selectedState[hobbyIndex]); }; let selectedHobbies = getSelectedHobbies(); const onFieldChange = async (checkboxIndex: number) => { const updatedCheckedState = checkboxState.map((checked, index) => index === checkboxIndex ? !checked : checked ); setCheckboxState(updatedCheckedState); // Check if the form is valid now that the values have changed const isFormValid = await formSchema.isValid( { hobbies: getSelectedHobbies(updatedCheckedState) }, { abortEarly: false // Prevent aborting validation after first error } ); // Remove checkbox error if needed if (checkboxError && isFormValid) { setCheckboxError(""); } }; const submitForm = async (e: FormEvent) => { e.preventDefault(); const isFormValid = await formSchema.isValid( { hobbies: selectedHobbies }, { abortEarly: false } ); if (isFormValid) { alert("Success!"); } else { // If the form is invalid, check which fields are incorrect // In our case, it can only be the "hobbies" field formSchema .validate({ hobbies: selectedHobbies }, { abortEarly: false }) .catch((err) => { const errors = err.inner.reduce( (acc: any, error: { path: any; message: any }) => { return { ...acc, [error.path]: error.message }; }, {} ); if (errors.hobbies) { setCheckboxError(errors.hobbies); } }); } }; return ( <form onSubmit={submitForm} noValidate> <fieldset // Let screen readers know when field is invalid aria-invalid={checkboxError ? "true" : "false"} // And where to look for the error aria-describedby="hobbies-error" > <legend>Hobbies</legend> {hobbies.map((hobby, hobbyIndex) => ( <Checkbox key={hobby} value={hobby} onChange={() => onFieldChange(hobbyIndex)} /> ))} {/* We set role="alert" on the error message */} <p className="error" id="hobbies-error" role="alert" aria-atomic="true"> {checkboxError} </p> </fieldset> <input type="submit" value="Submit" /> <output name="result" htmlFor="hobbies"> <pre> <code> {JSON.stringify({ selectedHobbies, error: checkboxError })} </code> </pre> </output> </form> ); }