Checkbox

A checkbox is a yes-or-no choice. It can be checked, unchecked or indeterminate, and it can be disabled.

import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";

export default function CheckboxDemo() {
  return (
    <>
      <Checkbox aria-label="Unchecked" />
      <Checkbox aria-label="Checked" defaultChecked />
      <Checkbox aria-label="Indeterminate" indeterminate />
      <Checkbox aria-label="Disabled" disabled />
      <Checkbox aria-label="Disabled and checked" disabled defaultChecked />
      <Label>
        <Checkbox defaultChecked />
        Accept the terms
      </Label>
    </>
  );
}

The code

The component is a single file, read from the repository when the site is built. Press Show code to see all of it.

import type { ComponentProps } from "react";
import { Check, Minus } from "lucide-react";
import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox";
import { cx } from "@/lib/cx";
import { focusRing } from "@/lib/styles";

// Checked and indeterminate fill with the accent. A disabled checkbox keeps its mark but loses
// the accent, and never reacts to hover. The two icons swap on the root's data-indeterminate.
const root = [
  "group inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border border-text-secondary text-bg",
  "transition-colors duration-150",
  focusRing,
  "not-data-[disabled]:hover:border-text",
  "not-data-[disabled]:data-[checked]:border-accent not-data-[disabled]:data-[checked]:bg-accent",
  "not-data-[disabled]:data-[indeterminate]:border-accent not-data-[disabled]:data-[indeterminate]:bg-accent",
  "data-[disabled]:cursor-not-allowed data-[disabled]:border-border data-[disabled]:bg-surface data-[disabled]:text-text-secondary",
].join(" ");

export function Checkbox({
  className,
  ...props
}: Omit<ComponentProps<typeof BaseCheckbox.Root>, "className"> & { className?: string }) {
  return (
    <BaseCheckbox.Root className={cx(root, className)} {...props}>
      <BaseCheckbox.Indicator className="flex">
        <Check aria-hidden strokeWidth={3} className="size-3.5 group-data-[indeterminate]:hidden" />
        <Minus aria-hidden strokeWidth={3} className="hidden size-3.5 group-data-[indeterminate]:block" />
      </BaseCheckbox.Indicator>
    </BaseCheckbox.Root>
  );
}

Props

PropTypeDefaultWhat it does
defaultCheckedbooleanfalseWhether it starts checked, when you let it manage its own state.
checkedboolean—The checked state, when you control it yourself.
onCheckedChangefunction—Called with the new value when the checkbox is toggled.
indeterminatebooleanfalseShows a dash instead of a check, for a group that is partly selected.
disabledbooleanfalseTurns off the checkbox and its hover state.
classNamestring—Extra classes, added after the checkbox's own.

Every other prop, such as name and value, goes to the Base UI Checkbox underneath.

The thinking

A checkbox answers one question with yes or no, and its box should show the answer without any explanation.

  • Built on Base UI: Base UI keeps the checked state, the hidden form input and the keyboard behavior. This file draws the box.
  • A mark for each state: A check for checked and a dash for indeterminate. They swap on a data attribute, with no script.
  • Put it in a label: Wrap the checkbox in a Label and the text becomes clickable too, which also makes the target larger.

Interface design

  • Size: A 20px square with a 1px outline and the sm radius, 4px.
  • Outline: The secondary text color, 4.2:1 against the page, so the box is easy to find when it is empty.
  • Checked: Fills with the accent and draws the mark in the page color, 14.6:1. Indeterminate looks the same, with a dash.
  • Disabled: A surface fill, a hairline border and a grey mark. A disabled checkbox that is checked still shows its mark.

Interaction

  • Toggle: Click it, press Space when it has focus, or click the text of its label.
  • Hover: An unchecked box's outline brightens to the main text color.
  • Focus: Keyboard focus draws a 2px outline, 2px away from the box. A mouse click doesn't show it.
  • Disabled: It doesn't toggle, doesn't react to hover, and shows a not-allowed cursor.

Empathy

  • Real semantics: It is announced as a checkbox with its checked state, and as mixed when indeterminate.
  • Always name it: A box needs text. Wrap it in a Label, or give it an aria-label as the bare boxes in the demo do.
  • Target size: The box alone is 20px, under the 24px minimum of WCAG 2.2 AA. Wrapped in a Label, the whole row is the target, so do that.