Components

Checkbox

A yes or a no: an option that is on or off, independent of any other.

Markdown

A checkbox holds a yes or a no: an option that is on or off, independent of any other.

Some, all, none

The first checkbox stands for the three beneath it: on when all are, off when none is, and mixed in between.

Code
"use client";

import { Checkbox } from "@publictheta/ui/components/checkbox/checkbox.tsx";
import { useState } from "react";

const KINDS = ["Mentions", "Replies", "Weekly summary"] as const;

export default function Notifications() {
  const [on, setOn] = useState<ReadonlySet<string>>(new Set(["Mentions"]));
  const all = on.size === KINDS.length ? true : on.size === 0 ? false : null;

  return (
    <div className="flex flex-col gap-2 text-sm text-fg-strong">
      <label className="flex items-center gap-2 font-medium">
        <Checkbox checked={all} onCheckedChange={(checked) => setOn(new Set(checked ? KINDS : []))} />
        All notifications
      </label>
      <div className="ms-6 flex flex-col gap-2">
        {KINDS.map((kind) => (
          <label key={kind} className="flex items-center gap-2">
            <Checkbox
              checked={on.has(kind)}
              onCheckedChange={(checked) =>
                setOn((current) => {
                  const next = new Set(current);
                  if (checked) next.add(kind);
                  else next.delete(kind);
                  return next;
                })
              }
            />
            {kind}
          </label>
        ))}
        <label className="flex items-center gap-2 text-fg-disabled">
          <Checkbox disabled />
          Text messages (no phone number)
        </label>
      </div>
    </div>
  );
}

When to use

Use a checkbox for a setting that takes effect with the rest of a form, and for choosing any number of items from a list. For a choice of exactly one among several, use a radio group or a select. For a setting that takes effect at once, a switch says so more plainly.

Best practices

Label what being checked means. Phrase the label so that checked is yes: "Send me updates", not "Don't send updates".

Make the label part of the target. A click or tap on the label changes the checkbox, as one on the box does.

Show a third state only when it is true. A checkbox is mixed when it stands for several things that disagree, or for a value not yet given. Choosing it turns it on; people cannot return it to mixed themselves.

Disable rather than hide a checkbox that does not apply at the moment.

Platform considerations

Keyboard. Space turns the checkbox on and off; Tab moves to and past it.

Touch. The box is larger, and a tap near it reaches it: its target is at least 44 CSS pixels across.

Assistive technology. It is a checkbox, named by its label, stating whether it is checked, unchecked or mixed.

Behaviour

Semantics

Is a checkbox named by its label, saying whether it is checked
const screen = await render(<Option defaultChecked />);
await expect.element(box(screen)).toBeChecked();
Neither on nor off is stated as mixed
const screen = await render(<Option checked={null} />);
await expect.element(box(screen)).toBePartiallyChecked();
Has no accessibility violations in any state
const screen = await render(
  <>
    <Option />
    <label>
      <Checkbox defaultChecked /> On
    </label>
    <label>
      <Checkbox checked={null} /> Mixed
    </label>
    <label>
      <Checkbox disabled defaultChecked /> Unavailable
    </label>
  </>,
);
await expectAccessible(screen.container);

Changing

A click turns it on, and another turns it off
const screen = await render(<Option />);
await userEvent.click(box(screen));
await expect.element(box(screen)).toBeChecked();
await userEvent.click(box(screen));
await expect.element(box(screen)).not.toBeChecked();
A click on its label does the same
const screen = await render(<Option />);
await userEvent.click(screen.getByText("Subscribe"));
await expect.element(box(screen)).toBeChecked();
Space turns it on and off
const screen = await render(
  <>
    <input aria-label="Before" />
    <Option />
  </>,
);
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
element(screen).focus();
await userEvent.keyboard(" ");
await expect.element(box(screen)).toBeChecked();
await userEvent.keyboard(" ");
await expect.element(box(screen)).not.toBeChecked();
From mixed, a click turns it on
const screen = await render(<Option defaultChecked={null} />);
await userEvent.click(box(screen));
await expect.element(box(screen)).toBeChecked();
expect(element(screen).indeterminate).toBe(false);
When disabled it cannot be changed
const onCheckedChange = vi.fn();
const screen = await render(<Option disabled onCheckedChange={onCheckedChange} />);
await userEvent.click(box(screen), { force: true });
expect(onCheckedChange).not.toHaveBeenCalled();
await expect.element(box(screen)).not.toBeChecked();

Touch

Under a finger, a tap 20 pixels from its centre still reaches itChromium ยท touch, WebKit ยท touch

Not yet written.

API

onCheckedChange hears each change with the new state
const onCheckedChange = vi.fn();
const screen = await render(<Option onCheckedChange={onCheckedChange} />);
await userEvent.click(box(screen));
await userEvent.click(box(screen));
expect(onCheckedChange.mock.calls).toEqual([[true], [false]]);
A controlled checked changes only when its owner changes it
function Controlled() {
  const [checked, setChecked] = useState<boolean | null>(null);
  return (
    <>
      <Option checked={checked} onCheckedChange={(next) => setChecked(next ? true : null)} />
      <output>{String(checked)}</output>
    </>
  );
}
const screen = await render(<Controlled />);
await userEvent.click(box(screen));
await expect.element(box(screen)).toBeChecked();
await userEvent.click(box(screen));
await expect.element(box(screen)).toBePartiallyChecked();
expect(element(screen).indeterminate).toBe(true);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/checkbox.json
Source

src/components/checkbox/checkbox.tsx

"use client";

import { useLayoutEffect, useRef } from "react";
import type { ComponentProps, ReactElement } from "react";
import { focusRingClassName, invalidClassName } from "../../lib/focus-class.ts";
import { mergeRefs } from "../../lib/merge-refs.ts";
import { cn } from "../../lib/utils.ts";
import { useControllableState } from "../../primitives/use-controllable-state.ts";

export type CheckboxProps = Omit<
  ComponentProps<"input">,
  "type" | "checked" | "defaultChecked" | "onChange"
> & {
  /** On, off, or `null` for neither: a mixed selection, or a value not yet given. */
  checked?: boolean | null;
  defaultChecked?: boolean | null;
  onCheckedChange?: (checked: boolean) => void;
};

/**
 * A checkbox: the platform's own, so it submits with forms, answers to Space
 * and to its label, and is announced as what it is. Only its looks are ours.
 */
export function Checkbox({
  checked: checkedProp,
  defaultChecked = false,
  onCheckedChange,
  className,
  ref,
  ...props
}: CheckboxProps): ReactElement {
  const input = useRef<HTMLInputElement>(null);
  const [checked, setChecked] = useControllableState<boolean | null>(
    checkedProp,
    defaultChecked,
    onCheckedChange as ((checked: boolean | null) => void) | undefined,
  );

  // Neither on nor off is a property of the element, not an attribute React sets.
  // After every render: a click clears it, even where the owner then keeps the value as it was.
  useLayoutEffect(() => {
    if (input.current !== null) input.current.indeterminate = checked === null;
  });

  return (
    <span
      className={cn("relative inline-flex size-4 shrink-0 align-middle pointer-coarse:size-6", className)}
    >
      <input
        type="checkbox"
        ref={mergeRefs(input, ref)}
        checked={checked === true}
        onChange={(event) => setChecked(event.target.checked)}
        className={cn(
          "peer size-full appearance-none rounded border border-border bg-surface [-webkit-tap-highlight-color:transparent]",
          "enabled:hover:border-fg-muted enabled:active:bg-surface-strong",
          "checked:border-transparent checked:bg-selected indeterminate:border-transparent indeterminate:bg-selected",
          "enabled:checked:active:bg-selected enabled:indeterminate:active:bg-selected",
          // Where a system forces its own colours, a box that is on is filled with the system's highlight.
          "forced-colors:checked:bg-[Highlight] forced-colors:indeterminate:bg-[Highlight]",
          focusRingClassName,
          invalidClassName,
          "disabled:border-border-muted disabled:bg-surface-muted disabled:checked:bg-surface-strong disabled:indeterminate:bg-surface-strong",
          // Under a finger, the target reaches beyond the box to 44 pixels across.
          "pointer-coarse:before:absolute pointer-coarse:before:-inset-2.5 pointer-coarse:before:content-['']",
        )}
        {...props}
      />
      <svg
        aria-hidden
        viewBox="0 0 16 16"
        fill="none"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
        className="pointer-events-none absolute inset-0 hidden size-full text-on-selected peer-checked:block peer-indeterminate:hidden peer-disabled:text-fg-muted forced-colors:text-[HighlightText]"
      >
        <path d="M3.75 8.25 6.75 11.25 12.25 5" />
      </svg>
      <svg
        aria-hidden
        viewBox="0 0 16 16"
        fill="none"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        className="pointer-events-none absolute inset-0 hidden size-full text-on-selected peer-indeterminate:block peer-disabled:text-fg-muted forced-colors:text-[HighlightText]"
      >
        <path d="M4 8h8" />
      </svg>
    </span>
  );
}