# Atoms — Swatch

A round colour chip for the **frame-colour selector** — the single-select set of colours a
customer picks a bike frame from. Built from Gazelle's **Tailwind v4 utilities**: each chip is a
`<label>` wrapping a visually-hidden native `<input type="radio">` (`peer sr-only`) plus a drawn
colour dot and an overlay ring, so keyboard, focus and form semantics come from the browser. The
copy field on the Atoms → Swatch screen emits this exact markup. A framework-free `.gz-swatch` API
in `components/swatch.css` mirrors the same design for projects not on Tailwind.

## Metadata

- **Category:** Atoms
- **Status:** Stable
- **Source:** `tailwind.css` (theme utilities) + `docs/atom-classes.js` (canonical class lists and
  `GZ_ATOM_MARKUP.swatchGroup`, shared with molecules) + `docs/atoms.js` (screen);
  `components/swatch.css` (`.gz-swatch` framework-free compatibility API)

## When to use

- Choosing exactly one frame colour from a small palette. Wrap the chips in a container with
  `role="radiogroup"` and give every `<input>` the same `name`, so the browser enforces
  single-select and arrow-key navigation.
- The interactive state is driven entirely by the real `<input>`: the ring shows on
  `peer-enabled:group-hover:` (hover) and `peer-checked:` (selected), and the dot takes a focus
  outline on `peer-focus-visible:`. Never toggle classes by hand.
- The frame colour is a **product value, not a design token**, so pass it inline —
  `style="background-color: #e5524d"` on the dot (mirrored as the `--swatch-color` custom property
  in the `.gz-swatch` API). Everything structural stays on tokenised utilities.

## When not to use

- Not for multi-select. If more than one colour can be on at once, that is a checkbox list, not a
  swatch group.
- Never drop the native `<input>`. The dot and ring are decorative; screen-reader and keyboard
  support depend on the real radio being present — visually hidden via `sr-only`, not `hidden`.
- Don't put a text label inside the chip. The colour name belongs on the input's `aria-label` (and
  optionally a caption beside the group), keeping the chip itself a pure colour target.
- In a Tailwind project, don't reach for the `.gz-swatch` compatibility classes — apply the
  utilities so the copied class list stays the single source of truth.

## Anatomy

```html
<div class="inline-flex items-center gap-4" role="radiogroup" aria-label="Frame colour">
  <label class="<swatch>">
    <input class="peer sr-only" type="radio" name="frame-colour" checked aria-label="Coral" />
    <span class="<dot>" style="background-color: #e5524d"></span>
    <span class="<ring>"></span>
  </label>
  <!-- one <label> per colour -->
</div>
```

Small size swaps the group gap to `gap-[14px]`, the dot to `size-[22px]` and the ring to
`size-[28px]`.

**Responsive size step.** `lg-tablet` keeps the Small size and adds `tablet:gap-4` (group),
`tablet:size-6` (dot) and `tablet:size-[30px]` (ring), so the selector is Small below tablet and
Large from tablet. The Bike card uses it (production `ColorSelector`: 22 → 24px dots, 14 → 16px
gap). Give each `<input>` a `value` (the colour's identifier) when a script needs to read the
choice, and a `name` unique to its card when several selectors share a page.

## Class lists

| Part | Utilities |
| --- | --- |
| **Group** | `inline-flex items-center gap-4` (small: `gap-[14px]`) |
| **Swatch (label)** | `group relative inline-flex cursor-pointer items-center justify-center has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-30` |
| **Input** | `peer sr-only` |
| **Dot** | `relative shrink-0 rounded-full size-6 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-[3px] peer-focus-visible:outline-focus-ring` (small: `size-[22px]`) |
| **Ring** | `pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 size-[30px] rounded-full border border-solid border-neutral-ink/50 opacity-0 transition-opacity duration-[120ms] peer-enabled:group-hover:opacity-100 peer-checked:border-surface-brand peer-checked:opacity-100` (small: `size-[28px]`) |

## States (from Figma)

| State | Look | Driven by |
| --- | --- | --- |
| Default | just the colour dot | base classes |
| Hover | dot + translucent-black ring (`neutral-ink/50`) | `peer-enabled:group-hover:opacity-100` |
| Selected | dot + solid brand-blue ring (`surface-brand`) | `peer-checked:` (opacity + border colour) |
| Focus | outline ring on the dot for keyboard users | `peer-focus-visible:` |

The two Figma sources disagree on the selected ring colour: the dedicated states component uses
`main/black/500` while the assembled selector uses `main/blue/500`. The **selector wins** —
selected is brand-blue (`surface-brand`). Hover stays translucent black, so hovering an unselected
chip previews with a neutral ring that turns brand-blue once actually chosen.

## Implementation notes

- **Ring colour precedence.** The ring's base border is `border-neutral-ink/50` (hover). The
  `peer-checked:border-surface-brand` variant has higher specificity, so a selected chip stays
  brand-blue even while hovered — no ordering tricks needed.
- **Ring geometry.** The ring is a border-only circle 6px larger than the dot (30/24 large,
  28/22 small), centred with `left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2`. It sits outside
  the dot fill, so the two never overlap and the dot colour reads cleanly.
- **Focus vs. ring.** Focus uses a dedicated `outline` on the dot (`outline-offset-[3px]`), not the
  ring, so keyboard focus is legible independently of selection — mirroring the checkbox/radio
  choice atoms rather than the button's `ring-*` treatment.
- **Dynamic fill.** Only the dot's `background-color` is a raw value; it is the frame colour, which
  is data, not a token. Keep every other property on utilities/tokens.

## Tokens & utilities

- **Ring colour** — `border-neutral-ink/50` (hover, = `main/black/500` at 50%),
  `border-surface-brand` (selected, = `main/blue/500`), `outline-focus-ring` (focus). Never a raw
  hex for the ring.
- **Dot fill** — inline `background-color` per instance (product colour). This is the one
  intentional exception to the no-raw-hex rule.
- **Geometry** — `size-6`/`size-[22px]` dot, `size-[30px]`/`size-[28px]` ring, `gap-4`/`gap-[14px]`
  group gap are fixed sizes against Figma, not `--space-*` steps.

## Common mistake

Toggling a class on the chip to fake "selected" instead of letting the real radio drive it. Every
visual state already follows from `peer-checked:` / `peer-enabled:group-hover:` /
`peer-focus-visible:` — a hand-set state will drift from the real interactive one and break
single-select semantics.

## Cross-references

- `specs/foundations/color.md` — the roles the ring/focus states resolve to.
- `specs/atoms/choice.md` — the other single-select control; contrast its filled control with the
  swatch's colour-dot-plus-ring treatment.
