# Checkbox

The check draws itself in, the box pops, and indeterminate morphs into a dash.

**Category:** Inputs  
**Exports:** `Checkbox`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/checkbox.json
```
The file lands at `components/ui/checkbox.tsx`; import from `@/components/ui/checkbox`.

## API — Checkbox
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `checked` | `boolean \| "indeterminate"` | — |  |
| `defaultChecked` | `boolean` | `false` |  |
| `onCheckedChange` | `(checked: boolean) => void` | — |  |
| `label` | `ReactNode` | — |  |
| `description` | `ReactNode` | — |  |
| `disabled` | `boolean` | — |  |

## Example — Checkbox

```tsx
import * as React from "react";
import { Checkbox } from "@/components/ui/checkbox";

const tasks = ["Design tokens", "Motion specs", "Accessibility pass"];

export default function CheckboxDemo({
  label = "Ship v1",
  description = "Three tasks before launch.",
  disabled = false,
}: {
  label?: string;
  description?: string;
  disabled?: boolean;
}) {
  const [done, setDone] = React.useState<string[]>(["Design tokens"]);
  const all = done.length === tasks.length;
  return (
    <div className="grid w-full max-w-xs gap-4">
      <Checkbox
        label={label}
        description={description}
        disabled={disabled}
        checked={all ? true : done.length ? "indeterminate" : false}
        onCheckedChange={() => setDone(all ? [] : tasks)}
      />
      <div className="grid gap-3 border-l pl-5">
        {tasks.map((t) => (
          <Checkbox
            key={t}
            label={t}
            disabled={disabled}
            checked={done.includes(t)}
            onCheckedChange={(c) => setDone((d) => (c ? [...d, t] : d.filter((x) => x !== t)))}
          />
        ))}
      </div>
    </div>
  );
}
```

## Source — components/ui/checkbox.tsx

```tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type CheckboxProps = Omit<React.ComponentProps<"button">, "onChange" | "value"> & {
  checked?: boolean | "indeterminate";
  defaultChecked?: boolean;
  onCheckedChange?: (checked: boolean) => void;
  label?: React.ReactNode;
  description?: React.ReactNode;
};

export function Checkbox({
  checked: checkedProp,
  defaultChecked = false,
  onCheckedChange,
  label,
  description,
  disabled,
  className,
  id: idProp,
  ...props
}: CheckboxProps) {
  const reduce = useReducedMotion();
  const autoId = React.useId();
  const id = idProp ?? autoId;
  const [inner, setInner] = React.useState(defaultChecked);
  const state = checkedProp ?? inner;
  const on = state === true;
  const mixed = state === "indeterminate";

  const toggle = () => {
    const next = !on;
    if (checkedProp === undefined) setInner(next);
    onCheckedChange?.(next);
  };

  const box = (
    <motion.button
      id={id}
      type="button"
      role="checkbox"
      aria-checked={mixed ? "mixed" : on}
      aria-describedby={description ? `${id}-desc` : undefined}
      disabled={disabled}
      onClick={toggle}
      whileTap={reduce || disabled ? undefined : { scale: 0.86 }}
      animate={on || mixed ? { scale: reduce ? 1 : [1, 1.12, 1] } : { scale: 1 }}
      transition={{ type: "spring", stiffness: 600, damping: 20 }}
      className={cn(
        "relative grid size-5 shrink-0 cursor-pointer place-items-center rounded-[6px] border outline-none transition-colors duration-150",
        "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        "disabled:cursor-not-allowed disabled:opacity-50",
        on || mixed ? "border-foreground bg-foreground text-background" : "border-input bg-background hover:border-muted-foreground",
        !label && className,
      )}
      {...(props as object)}
    >
      <svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={3.2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
        <motion.path
          d={mixed ? "M6 12h12" : "M5 12.5l4.5 4.5L19 7"}
          initial={false}
          animate={{ pathLength: on || mixed ? 1 : 0, opacity: on || mixed ? 1 : 0 }}
          transition={reduce ? { duration: 0 } : { pathLength: { duration: 0.22, ease: "easeOut" }, opacity: { duration: 0.05 } }}
        />
      </svg>
    </motion.button>
  );

  if (!label) return box;
  return (
    <div className={cn("flex items-start gap-3", disabled && "opacity-50", className)}>
      <div className="flex h-6 items-center">{box}</div>
      <div className="grid gap-0.5">
        <label htmlFor={id} className={cn("text-sm font-medium leading-6", disabled ? "cursor-not-allowed" : "cursor-pointer")}>
          {label}
        </label>
        {description && (
          <p id={`${id}-desc`} className="text-[13px] leading-5 text-muted-foreground">
            {description}
          </p>
        )}
      </div>
    </div>
  );
}
```