---
name: checkbox
type: core
domain: forms
requires: [loke-ui]
description: >
  Checkbox + CheckboxIndicator primitives. Checked/unchecked/indeterminate states via
  data-state. Hidden native input for form participation. Indeterminate-to-checked toggle
  cycle. Detached-form prop pattern. Label association required for accessible name.
---

# Checkbox

`@loke/ui/checkbox` — headless checkbox built on `Primitive.button` with a hidden native `<input type="checkbox">` for form participation.

**Exports:** `Checkbox`, `CheckboxIndicator`, `createCheckboxScope`

## Setup

```tsx
import { Checkbox, CheckboxIndicator } from "@loke/ui/checkbox";
import { Label } from "@loke/ui/label";

function AcceptTerms() {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
      <Checkbox id="terms" name="terms" defaultChecked={false}>
        <CheckboxIndicator>
          {/* style data-state="checked" and data-state="indeterminate" */}
          ✓
        </CheckboxIndicator>
      </Checkbox>
      <Label htmlFor="terms">Accept terms</Label>
    </div>
  );
}
```

`data-state` values: `"checked"` | `"unchecked"` | `"indeterminate"`

## Core Patterns

### Indeterminate state

Use `"indeterminate"` (the string literal) as the `CheckedState` value. On click, the component moves from `"indeterminate"` → `true`, not to `false`.

```tsx
import { useState } from "react";
import { Checkbox, CheckboxIndicator, type CheckedState } from "@loke/ui/checkbox";

function BulkSelect({ items }: { items: string[] }) {
  const [selected, setSelected] = useState<string[]>([]);
  const allChecked = selected.length === items.length;
  const someChecked = selected.length > 0 && !allChecked;

  const headState: CheckedState = allChecked ? true : someChecked ? "indeterminate" : false;

  return (
    <Checkbox
      checked={headState}
      onCheckedChange={(state) => {
        // state will be true when coming from indeterminate
        setSelected(state === true ? items : []);
      }}
    >
      <CheckboxIndicator>{someChecked ? "—" : "✓"}</CheckboxIndicator>
    </Checkbox>
  );
}
```

### Controlled state

```tsx
const [checked, setChecked] = useState<CheckedState>(false);

<Checkbox checked={checked} onCheckedChange={setChecked}>
  <CheckboxIndicator>✓</CheckboxIndicator>
</Checkbox>
```

### Form participation

When inside a `<form>`, a hidden `<input type="checkbox">` is rendered automatically. The `name` and `value` props map to it.

```tsx
<form action="/submit" method="post">
  <Checkbox name="newsletter" value="yes" defaultChecked>
    <CheckboxIndicator>✓</CheckboxIndicator>
  </Checkbox>
</form>
```

**Detached form** — if the Checkbox renders outside the `<form>` element, pass the `form` prop with the form's `id`:

```tsx
<form id="settings-form" onSubmit={handleSubmit} />

<Checkbox name="notifications" form="settings-form">
  <CheckboxIndicator>✓</CheckboxIndicator>
</Checkbox>
```

## Common Mistakes

### 1. Wrong indeterminate toggle expectation

**Wrong:** Assuming indeterminate → unchecked on click.

```tsx
// This will not behave as expected:
// indeterminate -> false -> true -> false ...
onCheckedChange={(state) => {
  if (state === "indeterminate") setChecked(false);
}}
```

**Correct:** The built-in cycle is `indeterminate → true → false → true`. Do not override the click handler to force `false` when the state is `"indeterminate"` — it fires *after* the state has already moved to `true`.

Source: `src/components/checkbox/checkbox.tsx` — toggle logic: `isIndeterminate(prevChecked) ? true : !prevChecked`

### 2. Detached form — hidden input not submitted

**Wrong:** Rendering Checkbox outside a `<form>` without the `form` prop.

The component uses `button.closest("form")` to detect its form. If no form ancestor is found, the hidden native input is not rendered and the value is not submitted.

**Correct:**

```tsx
<Checkbox name="active" form="my-form-id" defaultChecked>
  <CheckboxIndicator>✓</CheckboxIndicator>
</Checkbox>
```

Source: `src/components/checkbox/checkbox.tsx` — `BubbleInput` form detection

### 3. Missing label — no accessible name

Checkbox renders as `<button role="checkbox">`. Without an associated Label, screen readers announce it as unlabelled.

**Wrong:**

```tsx
<Checkbox>
  <CheckboxIndicator>✓</CheckboxIndicator>
</Checkbox>
<span>Agree to terms</span>
```

**Correct:** Either wrap or use `htmlFor`:

```tsx
<Label htmlFor="agree">
  <Checkbox id="agree">
    <CheckboxIndicator>✓</CheckboxIndicator>
  </Checkbox>
  Agree to terms
</Label>
```

Source: `src/components/checkbox/checkbox.tsx` — renders `Primitive.button`

## Cross-references

- **Label** (`@loke/ui/label`) — accessible labelling for Checkbox and Switch
- **Switch** (`@loke/ui/switch`) — use for binary on/off; no indeterminate state
- **Choosing the Right Component** — Checkbox vs Switch decision guide
