# Atoms — Checkbox & Radio

A matched pair of selection controls built from Gazelle's **Tailwind v4 utilities** — checkbox
for independent, multi-select choices, radio for mutually-exclusive single-select. Each is a
`<label>` wrapping a visually-hidden native `<input>` (`peer sr-only`) plus a drawn control, so
keyboard, focus and form semantics come from the browser. The copy field on the Atoms →
Checkbox / Radio screens emits this exact markup. A framework-free `.gz-checkbox` / `.gz-radio`
API in `components/choice.css` mirrors the same design for projects not on Tailwind.

## Metadata

- **Category:** Atoms
- **Status:** Stable
- **Source:** `tailwind.css` (theme utilities) + `docs/atoms.js` (canonical markup);
  `components/choice.css` (`.gz-checkbox` / `.gz-radio` framework-free compatibility API)

## When to use

- Checkbox — any number of options can be on at once (`type="checkbox"`, control `rounded-sm`).
- Radio — exactly one option in a `name`-grouped set can be on (`type="radio"`, control
  `rounded-full`); always give every radio in a set the same `name`.
- The interactive state is driven entirely by the real `<input>`: style the control off its
  sibling state with `peer-checked:`, `peer-focus-visible:`, `peer-disabled:` and the row's
  `group-hover:` / `group-has-[:checked]:` — never toggle classes by hand. The hover border is
  guarded with `peer-enabled:group-hover:` so a disabled control doesn't react to hover.
- Disable the native `<input>` (`disabled`) rather than dimming the label yourself — the row's
  `has-[:disabled]:opacity-30` and `has-[:disabled]:cursor-not-allowed` already follow from it.

## When not to use

- Never drop the native `<input>`. The visible box/dot is decorative; screen-reader and keyboard
  support depend on the real control being present — visually hidden via `sr-only`, not
  `hidden`.
- Don't copy the button's `ring-*` focus treatment onto a choice control. Checkbox/radio use
  `peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-focus-ring`
  so the ring can sit outside a circular control without clipping.
- In a Tailwind project, don't reach for the `.gz-checkbox` / `.gz-radio` compatibility classes —
  apply the utilities so the copied class list stays the single source of truth.

## Anatomy

```html
<label class="<row>">
  <input class="peer sr-only" type="checkbox" />
  <span class="<control> rounded-sm">
    <svg class="<check-icon>" viewBox="0 0 24 24" fill="none" aria-hidden="true"
         stroke-width="2"><path d="M5 13L9 17L19 7" stroke="currentColor"
         stroke-linecap="round" stroke-linejoin="round" /></svg>
  </span>
  <span class="<label>">Rechtop</span>
  <span class="<meta>">5</span>   <!-- optional -->
</label>
```

Radio swaps the control to `rounded-full` and replaces the check `<svg>` with a
`<span class="<radio-mark>"></span>` disc; use `type="radio"` with a shared `name`.

## Class lists

| Part | Utilities |
| --- | --- |
| **Row** | `group relative inline-flex cursor-pointer items-center gap-3 font-brand text-fg-primary has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-30` |
| **Input** | `peer sr-only` |
| **Control (base)** | `relative z-0 flex size-6 shrink-0 items-center justify-center border border-neutral-grey-500 bg-white transition-colors duration-[120ms]` + halo `before:*` + `peer-enabled:group-hover:border-surface-brand group-hover:before:opacity-100 peer-checked:border-surface-brand peer-checked:bg-surface-brand peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-focus-ring peer-disabled:before:opacity-0` |
| Control shape | checkbox `rounded-sm` · radio `rounded-full` |
| **Hover halo** (`before:`) | `before:pointer-events-none before:absolute before:left-1/2 before:top-1/2 before:-z-10 before:size-10 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-surface-brand/5 before:opacity-0 before:transition-opacity before:duration-[120ms]` |
| **Check icon** | `size-5 shrink-0 scale-75 text-white opacity-0 transition-[opacity,transform] duration-[90ms] group-has-[:checked]:scale-100 group-has-[:checked]:opacity-100` |
| **Radio mark** | `size-[10px] scale-0 rounded-full bg-white transition-transform duration-[90ms] group-has-[:checked]:scale-100` |
| **Label** | `font-light text-16 leading-100 tracking-[-0.025em] text-fg-primary` |
| **Meta** | `font-light text-14 leading-100 tracking-[-0.025em] text-fg-muted whitespace-nowrap` |

## States (from Figma)

| State | Look | Driven by |
| --- | --- | --- |
| Unselected | white fill, `neutral-grey-500` border | base control classes |
| Unselected hover | brand border + 40px brand halo at 5% (border only when enabled) | `peer-enabled:group-hover:` (border) · `group-hover:` (halo) |
| Selected | `surface-brand` fill, white check / dot | `peer-checked:` |
| Selected hover | same fill; halo also shows | `peer-checked:` + `group-hover:` |
| Inactive | whole row at 30% opacity, `not-allowed` | `has-[:disabled]:` |
| Focus | outline ring (+ halo) for keyboard users | `peer-focus-visible:` |

## Tokens & utilities

- **Colour** — `bg-surface-brand` (selected), `border-neutral-grey-500` (unselected border),
  `outline-focus-ring` (focus), `bg-surface-brand/5` (halo). Never a raw hex.
- **Label/meta type** — `font-light`, `text-16` / `text-14`, `leading-100`,
  `tracking-[-0.025em]`. Like the button, this is a bespoke component-local scale (note the
  arbitrary `-0.025em` tracking), not a shared `.gz-text-*` role — match these exact utilities if
  you extend the component.
- **Control geometry** — `size-6` box/dot, `before:size-10` halo, `size-5` check, `size-[10px]`
  dot are fixed sizes against Figma, not `--space-*` steps.

## Common mistake

Toggling classes on the control to fake a state instead of letting the real input drive it. Every
visual state already follows from `peer-checked:` / `peer-disabled:` / `peer-focus-visible:` plus
the row's `group-hover:` / `group-has-[:checked]:` — a hand-set state will drift from the real
interactive one.

## Cross-references

- `specs/foundations/color.md` — the roles the fill/border/focus states resolve to.
- `specs/atoms/button.md` — the other shipped atom family; contrast its `ring-*` focus with this
  control's `outline-*`.
- `specs/icons/icons.md` — the checkbox tick reuses the icon package's `check` glyph.
