# Checkbox

> Checkbox with a drawn-on checkmark and indeterminate state.

Category: Forms. Docs: https://get-neonkit.vercel.app/components/checkbox

## Installation

```bash
npx shadcn@latest add https://get-neonkit.vercel.app/r/checkbox.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/checkbox`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Checkbox } from "@/components/ui/checkbox"

<Checkbox label="Accept terms and conditions" />
```

## Examples

### With description

```tsx
import { Checkbox } from "@/components/ui/checkbox";

export default function CheckboxDemo() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-5">
      <Checkbox
        defaultChecked
        label="Accept terms and conditions"
        description="You agree to our Terms of Service and Privacy Policy."
      />
      <Checkbox
        label="Send me product updates"
        description="One email a month about new components."
      />
    </div>
  );
}
```

### Select all

indeterminate shows a dash on the parent while only some children are checked.

```tsx
"use client";

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

const channels = [
  { value: "email", label: "Email" },
  { value: "push", label: "Push notifications" },
  { value: "sms", label: "Text message" },
];

export default function CheckboxSelectAll() {
  const id = React.useId();
  const [selected, setSelected] = React.useState<string[]>(["email", "push"]);

  const all = selected.length === channels.length;
  const some = selected.length > 0 && !all;

  function toggle(value: string, checked: boolean) {
    setSelected((current) =>
      checked ? [...current, value] : current.filter((item) => item !== value),
    );
  }

  return (
    <fieldset className="w-full max-w-xs">
      <legend className="mb-4 text-sm text-white/55">Notify me about mentions by</legend>
      <Checkbox
        checked={all}
        indeterminate={some}
        onCheckedChange={(checked) => setSelected(checked ? channels.map((c) => c.value) : [])}
        aria-controls={channels.map((c) => `${id}-${c.value}`).join(" ")}
        label="All channels"
      />
      <div className="mt-3 flex flex-col gap-3 pl-8">
        {channels.map((channel) => (
          <Checkbox
            key={channel.value}
            id={`${id}-${channel.value}`}
            checked={selected.includes(channel.value)}
            onCheckedChange={(checked) => toggle(channel.value, checked)}
            label={channel.label}
          />
        ))}
      </div>
    </fieldset>
  );
}
```

### Sizes

```tsx
import { Checkbox } from "@/components/ui/checkbox";

export default function CheckboxSizes() {
  return (
    <div className="flex flex-col gap-4">
      <Checkbox size="sm" defaultChecked label="Small" />
      <Checkbox size="md" defaultChecked label="Medium" />
      <Checkbox size="lg" defaultChecked label="Large" />
    </div>
  );
}
```

### Colors

Pass a palette name or any hex value to color.

```tsx
import { Checkbox } from "@/components/ui/checkbox";

const colors = ["cyan", "blue", "violet", "magenta", "pink", "red", "orange", "amber", "lime", "green"] as const;

export default function CheckboxColors() {
  return (
    <div className="grid w-full max-w-md gap-5">
      <div className="flex flex-wrap justify-center gap-x-6 gap-y-3">
        {colors.map((color) => (
          <Checkbox key={color} color={color} defaultChecked label={color} className="capitalize" />
        ))}
      </div>
      <div className="flex justify-center">
        <Checkbox color="#ff6a00" defaultChecked label="Any hex works · #ff6a00" />
      </div>
    </div>
  );
}
```

### Disabled

```tsx
import { Checkbox } from "@/components/ui/checkbox";

export default function CheckboxDisabled() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-5">
      <Checkbox
        disabled
        label="Sync over cellular"
        description="Available on the Pro plan."
      />
      <Checkbox
        disabled
        defaultChecked
        label="Encrypt backups"
        description="Required by your organization's security policy."
      />
    </div>
  );
}
```

## API reference

### Checkbox

Renders a native checkbox input. className and style go to the outer label; every other native input attribute goes to the input, which also receives the ref.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `checked` | `boolean` | — | Controlled checked state. |
| `defaultChecked` | `boolean` | `false` | Initial state when uncontrolled. |
| `onCheckedChange` | `(checked: boolean) => void` | — | Called with the new state on every toggle. |
| `indeterminate` | `boolean` | `false` | Shows a dash and reports a mixed state. Takes priority over checked visually. |
| `label` | `ReactNode` | — | Text beside the box. Clicking anywhere on the row toggles it. |
| `description` | `ReactNode` | — | Secondary text under the label, linked with aria-describedby. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Box of 16, 20 or 24px with matching text. |
| `disabled` | `boolean` | `false` | Blocks interaction and dims the row. |

## Accessibility

- It is a real input, so Space toggles it, forms submit it and name, value and required work as usual.
- The label is wired with aria-labelledby, so the description is announced as a description rather than as part of the name.
- Indeterminate sets the native indeterminate property, which screen readers announce as mixed.
- Without a label, pass an aria-label.

## Source

`components/ui/checkbox.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

// Each box is as tall as its label's line-height (or offset to its center),
// so box and first line of text share a center.
const sizes = {
  sm: {
    root: "gap-2.5",
    control: "mt-0.5 size-4",
    box: "rounded-[5px]",
    stroke: 2,
    label: "text-[13px] leading-5",
    description: "text-xs leading-4",
  },
  md: {
    root: "gap-3",
    control: "size-5",
    box: "rounded-md",
    stroke: 1.75,
    label: "text-sm leading-5",
    description: "mt-0.5 text-[13px] leading-[18px]",
  },
  lg: {
    root: "gap-3",
    control: "size-6",
    box: "rounded-[7px]",
    stroke: 1.6,
    label: "text-base leading-6",
    description: "mt-0.5 text-sm leading-5",
  },
} as const;

export interface CheckboxProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "color" | "size" | "type"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  size?: keyof typeof sizes;
  onCheckedChange?: (checked: boolean) => void;
  /** Shows a dash and reports "mixed" to assistive tech. */
  indeterminate?: boolean;
  label?: React.ReactNode;
  description?: React.ReactNode;
}

const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
  (
    {
      className,
      style,
      color,
      size = "md",
      checked: checkedProp,
      defaultChecked = false,
      onCheckedChange,
      onChange,
      indeterminate = false,
      label,
      description,
      disabled,
      id: idProp,
      "aria-describedby": describedBy,
      ...props
    },
    ref,
  ) => {
    const inputRef = React.useRef<HTMLInputElement>(null);
    React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []);

    const autoId = React.useId();
    const id = idProp ?? autoId;
    const labelId = `${id}-label`;
    const descriptionId = `${id}-description`;

    const [uncontrolled, setUncontrolled] = React.useState(defaultChecked);
    const isControlled = checkedProp !== undefined;
    const checked = isControlled ? checkedProp : uncontrolled;

    // A click clears the native flag, so re-apply it after every render.
    React.useEffect(() => {
      if (inputRef.current) inputRef.current.indeterminate = indeterminate;
    });

    const state = indeterminate ? "indeterminate" : checked ? "checked" : "unchecked";
    const on = state !== "unchecked";
    const s = sizes[size];

    function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
      onChange?.(event);
      if (!isControlled) setUncontrolled(event.target.checked);
      onCheckedChange?.(event.target.checked);
    }

    return (
      <label
        data-state={state}
        data-disabled={disabled ? "" : undefined}
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "group/checkbox inline-flex cursor-pointer select-none items-start",
          disabled && "cursor-not-allowed opacity-50",
          s.root,
          className,
        )}
      >
        <span className={cn("relative inline-flex shrink-0", s.control)}>
          <input
            ref={inputRef}
            type="checkbox"
            id={id}
            checked={checked}
            disabled={disabled}
            onChange={handleChange}
            aria-labelledby={label ? labelId : undefined}
            aria-describedby={
              [describedBy, description && descriptionId].filter(Boolean).join(" ") ||
              undefined
            }
            className="peer absolute inset-0 m-0 size-full cursor-[inherit] appearance-none opacity-0"
            {...props}
          />
          <span
            aria-hidden
            className={cn(
              "pointer-events-none flex size-full items-center justify-center border text-[#05060a]",
              "transition-[background-color,border-color,box-shadow,transform] duration-150 ease-out motion-reduce:transition-none",
              "peer-focus-visible:ring-2 peer-focus-visible:ring-[rgb(var(--neon)/0.6)] peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-[#05060a]",
              !disabled && "group-active/checkbox:scale-[0.92]",
              s.box,
              on
                ? "border-[rgb(var(--neon))] bg-[rgb(var(--neon))] shadow-[0_0_14px_rgb(var(--neon)/0.45),inset_0_1px_0_rgb(255_255_255/0.35)]"
                : "border-white/20 bg-white/[0.03]",
              !disabled &&
                (on
                  ? "group-hover/checkbox:shadow-[0_0_20px_rgb(var(--neon)/0.6),inset_0_1px_0_rgb(255_255_255/0.35)]"
                  : "group-hover/checkbox:border-[rgb(var(--neon)/0.55)]"),
            )}
          >
            <svg
              viewBox="0 0 16 16"
              fill="none"
              stroke="currentColor"
              strokeWidth={s.stroke}
              strokeLinecap="round"
              strokeLinejoin="round"
              className="size-full"
            >
              <Stroke d="M3.75 8.25 6.5 11l5.75-6" visible={state === "checked"} />
              <Stroke d="M4.5 8h7" visible={state === "indeterminate"} />
            </svg>
          </span>
        </span>

        {(label || description) && (
          <span className="grid min-w-0">
            {label && (
              <span
                id={labelId}
                className={cn("text-white/90", description && "font-medium", s.label)}
              >
                {label}
              </span>
            )}
            {description && (
              <span id={descriptionId} className={cn("text-white/55", s.description)}>
                {description}
              </span>
            )}
          </span>
        )}
      </label>
    );
  },
);
Checkbox.displayName = "Checkbox";

/** A path that draws itself on. Hidden instantly so no round cap lingers. */
function Stroke({ d, visible }: { d: string; visible: boolean }) {
  return (
    <path
      d={d}
      pathLength={1}
      strokeDasharray={1}
      className={cn(
        "transition-[stroke-dashoffset] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
        visible
          ? "opacity-100 delay-[40ms] duration-200 [stroke-dashoffset:0]"
          : "opacity-0 duration-0 [stroke-dashoffset:1]",
      )}
    />
  );
}

export { Checkbox };
```
