Forms

Checkbox

Checkbox with an indeterminate state and a link inside its label

Preview

size
label
link
indeterminate
required
error
disabled

Code

import css from "./Checkbox.module.css";
import { useEffect, useRef } from "react";

import { CheckIcon } from "@phosphor-icons/react/dist/csr/Check";
import { MinusIcon } from "@phosphor-icons/react/dist/csr/Minus";

type Size = "xs" | "sm" | "md" | "lg";

interface ResponsiveSize {
  base: Size;
  mobile?: Size;
  tablet?: Size;
  desktop?: Size;
  wide?: Size;
}

interface CheckboxLink {
  label: string;
  href: string;
  target?: string;
}

interface CheckboxProps {
  size: Size | ResponsiveSize;
  name?: string;
  label?: string;
  link?: CheckboxLink;
  "aria-label"?: string;
  indeterminate?: boolean;
  checked: boolean;
  required?: boolean;
  error?: boolean;
  disabled?: boolean;
  onChange: (checked: boolean) => void;
}

function getSizeClasses(size: Size | ResponsiveSize): string {
  if (typeof size === "string") {
    return css[size];
  }

  const classes = [css[`${size.base}_base`]];

  if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
  if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
  if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
  if (size.wide) classes.push(css[`${size.wide}_wide`]);

  return classes.join(" ");
}

export default function Checkbox({
  size,
  name,
  label,
  link,
  "aria-label": ariaLabel,
  indeterminate,
  checked,
  required,
  error,
  disabled,
  onChange,
}: CheckboxProps) {
  const input = useRef<HTMLInputElement>(null);

  const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
    onChange(ev.target.checked);
  };

  // A DOM property with no HTML attribute: only script can set it.
  useEffect(() => {
    input.current!.indeterminate = indeterminate ?? false;
  }, [indeterminate]);

  const Wrapper = label || link ? "label" : "span";

  return (
    <Wrapper
      className={`
        ${css.checkbox}
        ${getSizeClasses(size)}
        ${checked ? css.checked : ""}
        ${indeterminate ? css.indeterminate : ""}
        ${error ? css.error : ""}
        ${disabled ? css.disabled : ""}
      `}
    >
      <input
        ref={input}
        type="checkbox"
        name={name}
        checked={checked}
        required={required}
        disabled={disabled}
        aria-label={label || link ? undefined : ariaLabel}
        aria-invalid={error}
        onChange={handleChange}
      />
      <span className={css.checkbox_box}>
        {indeterminate ? (
          <MinusIcon className={css.box_icon} weight="bold" aria-hidden="true" />
        ) : (
          <CheckIcon className={css.box_icon} weight="bold" aria-hidden="true" />
        )}
      </span>
      {(label || link) && (
        <span className={css.checkbox_label}>
          {label}
          {label && link && " "}
          {link && (
            <a
              className={css.label_link}
              href={link.href}
              target={link.target}
              rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
            >
              {link.label}
            </a>
          )}
          {required && <span aria-hidden="true">*</span>}
        </span>
      )}
    </Wrapper>
  );
}

Props

PropTypeRequiredDescription
size"xs" | "sm" | "md" | "lg" | ResponsiveSizerequiredCheckbox size (box, icon and label). Fixed or responsive.
namestring—Field name when it lives in a form; without it, it is not submitted.
labelstring—Text next to the checkbox. Without label or link, only the box remains.
link{ label: string; href: string; target?: string }—Link inside the label, after the text. Clicking it does not toggle the box.
aria-labelstring—Name of the control for screen readers. Required when there is no label or link; ignored when there is.
indeterminateboolean—Half-way state, with a dash in the box. It is the "select all" when only some rows are checked. Announced as "mixed".
requiredboolean—Marks the field as required (adds * to the label and the native required).
errorboolean—Visual error state (red border, aria-invalid).
disabledboolean—Disables the checkbox (not interactive, reduced opacity).
checkedbooleanrequiredChecked state (controlled).
onChange(checked: boolean) => voidrequiredCallback with the new state on every change.