Back to Blog
ReactJS

React Checkboxes: Building Accessible Checkbox Components from Scratch

19 July 20266 min read
React Checkboxes: Building Accessible Checkbox Components from Scratch

Master React checkboxes from basic controlled inputs to complex checkbox groups with indeterminate state, custom styling, and full WCAG accessibility compliance.

Checkboxes are deceptively simple — until you need indeterminate state, keyboard navigation, accessible labels, custom styling, or a "Select All" pattern. This guide covers every checkbox pattern you'll encounter in React applications, from basic controlled inputs to fully accessible checkbox groups.

Basic Controlled Checkbox

In React, a controlled checkbox ties its checked state to React state:

import { useState } from 'react';

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

  return (
    <label className="flex items-center gap-2 cursor-pointer">
      <input
        type="checkbox"
        checked={isChecked}
        onChange={(e) => setIsChecked(e.target.checked)}
        className="w-4 h-4 accent-blue-600"
      />
      <span>Subscribe to newsletter</span>
    </label>
  );
}

Key points:

  • Always wrap input in a <label> — clicking the label text toggles the checkbox

  • Use e.target.checked, not e.target.value, for checkboxes

  • CSS accent-color (the accent- Tailwind prefix) styles the native checkbox in modern browsers

Reusable Checkbox Component

// components/Checkbox.tsx
import { InputHTMLAttributes, forwardRef } from 'react';

interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
  label: string;
  description?: string;
}

const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
  ({ label, description, id, className, ...props }, ref) => {
    const checkboxId = id || `checkbox-${label.toLowerCase().replace(/\s+/g, '-')}`;

    return (
      <div className="flex gap-3">
        <div className="flex items-center h-5">
          <input
            ref={ref}
            id={checkboxId}
            type="checkbox"
            className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
            aria-describedby={description ? `${checkboxId}-desc` : undefined}
            {...props}
          />
        </div>
        <div>
          <label htmlFor={checkboxId} className="text-sm font-medium text-gray-900 cursor-pointer">
            {label}
          </label>
          {description && (
            <p id={`${checkboxId}-desc`} className="text-sm text-gray-500">{description}</p>
          )}
        </div>
      </div>
    );
  }
);

Checkbox.displayName = 'Checkbox';
export default Checkbox;

// Usage
<Checkbox
  label="Send marketing emails"
  description="We'll send you product updates and promotions."
  checked={receiveEmails}
  onChange={(e) => setReceiveEmails(e.target.checked)}
/>

Indeterminate State

The indeterminate state (the dash shown when some but not all items in a group are selected) can only be set via the DOM — not through JSX attributes. Use a ref:

import { useRef, useEffect } from 'react';

interface SelectAllCheckboxProps {
  checked: boolean;
  indeterminate: boolean;
  onChange: (checked: boolean) => void;
  label: string;
}

function SelectAllCheckbox({ checked, indeterminate, onChange, label }: SelectAllCheckboxProps) {
  const ref = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (ref.current) {
      ref.current.indeterminate = indeterminate;
    }
  }, [indeterminate]);

  return (
    <label className="flex items-center gap-2 cursor-pointer font-medium">
      <input
        ref={ref}
        type="checkbox"
        checked={checked}
        onChange={(e) => onChange(e.target.checked)}
        className="w-4 h-4"
      />
      {label}
    </label>
  );
}

Checkbox Group with Select All

const FRUITS = ['Apple', 'Banana', 'Cherry', 'Dragonfruit', 'Elderberry'];

function FruitSelector() {
  const [selected, setSelected] = useState<Set<string>>(new Set());

  const allChecked = selected.size === FRUITS.length;
  const someChecked = selected.size > 0 && !allChecked;

  function toggleAll(checked: boolean) {
    setSelected(checked ? new Set(FRUITS) : new Set());
  }

  function toggleOne(fruit: string, checked: boolean) {
    setSelected(prev => {
      const next = new Set(prev);
      checked ? next.add(fruit) : next.delete(fruit);
      return next;
    });
  }

  return (
    <fieldset>
      <legend className="text-base font-semibold mb-3">Select fruits</legend>

      <SelectAllCheckbox
        checked={allChecked}
        indeterminate={someChecked}
        onChange={toggleAll}
        label="Select all"
      />

      <div className="mt-2 ml-6 space-y-2">
        {FRUITS.map(fruit => (
          <label key={fruit} className="flex items-center gap-2 cursor-pointer">
            <input
              type="checkbox"
              checked={selected.has(fruit)}
              onChange={(e) => toggleOne(fruit, e.target.checked)}
              className="w-4 h-4"
            />
            <span className="text-sm">{fruit}</span>
          </label>
        ))}
      </div>

      <p className="mt-3 text-sm text-gray-500">
        {selected.size} of {FRUITS.length} selected: {[...selected].join(', ') || 'none'}
      </p>
    </fieldset>
  );
}

Custom Styled Checkbox (Hiding Native, Using CSS)

When accent-color isn't enough and you need full design control:

/* styles/checkbox.css */
.custom-checkbox-wrapper {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  cursor: pointer;
  user-select: none;
}

/* Hide native checkbox visually but keep it accessible */
.custom-checkbox-wrapper input[type="checkbox"] {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}

/* Custom visual box */
.custom-checkbox-wrapper .checkmark {
  width: 18px;
  height: 18px;
  border: 2px solid #d1d5db;
  border-radius: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: white;
  transition: all 0.15s ease;
  flex-shrink: 0;
}

/* Checked state */
.custom-checkbox-wrapper input:checked + .checkmark {
  background: #0070DC;
  border-color: #0070DC;
}

/* Checkmark tick */
.custom-checkbox-wrapper input:checked + .checkmark::after {
  content: '';
  width: 5px;
  height: 9px;
  border: 2px solid white;
  border-top: none;
  border-left: none;
  transform: rotate(45deg) translateY(-1px);
}

/* Focus ring for keyboard navigation */
.custom-checkbox-wrapper input:focus-visible + .checkmark {
  outline: 2px solid #0070DC;
  outline-offset: 2px;
}
// React component using custom checkbox
function CustomCheckbox({ label, checked, onChange, id }: CheckboxProps) {
  const inputId = id || `custom-${label}`;
  return (
    <label htmlFor={inputId} className="custom-checkbox-wrapper">
      <input
        id={inputId}
        type="checkbox"
        checked={checked}
        onChange={(e) => onChange(e.target.checked)}
      />
      <span className="checkmark" aria-hidden="true" />
      <span>{label}</span>
    </label>
  );
}

Checkbox with React Hook Form

import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';

const schema = z.object({
  agreeToTerms: z.boolean().refine(val => val === true, {
    message: 'You must accept the terms of service',
  }),
  receiveMarketing: z.boolean().optional(),
});

function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
    defaultValues: { agreeToTerms: false, receiveMarketing: false },
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <div>
        <label className="flex items-center gap-2">
          <input type="checkbox" {...register('agreeToTerms')} />
          I accept the <a href="/terms">terms of service</a>
        </label>
        {errors.agreeToTerms && (
          <p className="text-red-600 text-sm mt-1" role="alert">
            {errors.agreeToTerms.message}
          </p>
        )}
      </div>

      <label className="flex items-center gap-2 mt-3">
        <input type="checkbox" {...register('receiveMarketing')} />
        Send me product updates
      </label>

      <button type="submit" className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
        Sign up
      </button>
    </form>
  );
}

Accessibility Requirements

  • Always use a <label> — either wrap the input, or use htmlFor + id

  • Use <fieldset> + <legend> for checkbox groups — screen readers announce the group context

  • Never disable focus rings — add a visible :focus-visible ring for keyboard users

  • Error messages — use aria-describedby to associate errors with the checkbox, and role="alert" on the error element

  • Required checkboxes — use aria-required="true" and communicate the requirement in the label text, not just colour

  • Custom styled checkboxes — keep the native <input type="checkbox"> in the DOM (visually hidden, not display:none) so screen readers and keyboard navigation still work

Conclusion

React checkboxes range from a three-line controlled input to complex accessible group components with indeterminate state and custom styling. The key principles are: always use semantic <label> elements, handle indeterminate state via a ref, use Set for efficient group state management, and never remove the native input from the accessibility tree when custom styling. With React Hook Form integration, checkbox groups become part of your overall form validation strategy seamlessly.

Was this article helpful?

C

Written by

CoderCrafter Team

Developer & Technical Writer

Building developer tools and writing practical engineering content at CoderCrafter. Covers JavaScript, React, Node.js, DevOps, and modern web development.

Comments

Leave a comment

0/2000

Comments are reviewed before appearing. Be kind and constructive.

Call UsWhatsApp