Loading...

Skip to main content

Accessible custom checkboxes

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. We’re going to wrap our checkbox inputs in their label, to make the whole label clickable.

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 ALL_HOBBIES = ["Hiking", "Reading", "Sleeping", "Coding"] as const; type Hobby = (typeof ALL_HOBBIES)[number];

We can use map() to render our checkboxes:

export default function Form() { const [selectedHobbies, setSelectedHobbies] = useState<Hobby[]>([]); const [checkboxError, setCheckboxError] = useState<string>(""); const onFieldChange = async (fieldValue: Hobby) => { // Check if the value is already selected, then add/remove it from the array const updatedHobbies = selectedHobbies.includes(fieldValue) ? selectedHobbies.filter((selectedHobby) => selectedHobby !== fieldValue) : [...selectedHobbies, fieldValue]; setSelectedHobbies(updatedHobbies); ); return ( <form> <fieldset aria-invalid="false" aria-describedby="hobbies-error"> <legend>Hobbies</legend> {ALL_HOBBIES.map((hobby) => ( <Checkbox key={hobby} value={hobby} onChange={() => onFieldChange(hobby)} /> ))} <p className="error" id="hobbies-error" role="alert" aria-atomic="true"> {checkboxError} </p> </fieldset> </form> ); }
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 [selectedHobbies, setSelectedHobbies] = useState<Hobby[]>([]); const [checkboxError, setCheckboxError] = useState<string>(""); const onFieldChange = async (fieldValue: Hobby) => { const updatedHobbies = selectedHobbies.includes(fieldValue) ? selectedHobbies.filter((selectedHobby) => selectedHobby !== fieldValue) : [...selectedHobbies, fieldValue]; setSelectedHobbies(updatedHobbies); // Check if the form is valid now that the values have changed const isFormValid = await formSchema.isValid( { hobbies: updatedHobbies }, { 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> {* ... */} </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 (fieldValue: Hobby) => { const updatedHobbies = selectedHobbies.includes(fieldValue) ? selectedHobbies.filter((selectedHobby) => selectedHobby !== fieldValue) : [...selectedHobbies, fieldValue]; setSelectedHobbies(updatedHobbies); // Check if the form is valid now that the values have changed const isFormValid = await formSchema.isValid( { hobbies: updatedHobbies }, { 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 ALL_HOBBIES = ["Hiking", "Reading", "Sleeping", "Coding"] as const;
    type Hobby = typeof ALL_HOBBIES[number];
    
    export default function Form() {
      const [selectedHobbies, setSelectedHobbies] = useState<Hobby[]>([]);
      const [checkboxError, setCheckboxError] = useState<string>("");
    
      const onFieldChange = async (fieldValue: Hobby) => {
        const updatedHobbies = selectedHobbies.includes(fieldValue)
          ? selectedHobbies.filter((selectedHobby) => selectedHobby !== fieldValue)
          : [...selectedHobbies, fieldValue];
    
        setSelectedHobbies(updatedHobbies);
    
        // Check if the form is valid now that the values have changed
        const isFormValid = await formSchema.isValid(
          { hobbies: updatedHobbies },
          {
            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>
            {ALL_HOBBIES.map((hobby) => (
              <Checkbox
                key={hobby}
                value={hobby}
                onChange={() => onFieldChange(hobby)}
              />
            ))}
            {/* 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>
      );
    }