> ## Oymyakon DS index
> Fetch the full index at: https://super-dollop-pzmo65r.pages.github.io/llms.txt
> HTML preview: https://super-dollop-pzmo65r.pages.github.io/checkbox.html
> Source: specs/components/checkbox/checkbox.md, specs/components/checkbox/checkbox-a11y.md, specs/components/checkbox/capabilities.json

---

# Checkbox · Oymyakon DS 3

> A binary selection control: a s24 box that carries a check glyph when selected, wrapped in a
> s48 touch target.

**Version:** 3.1.2 · **Status:** Ready · **Figma:** `[Checkbox] 3.1` (node `17855:6623`)

---

## 1. Description

Checkbox lets a person turn a single option on or off, or pick any number of items from a set. It
is used in forms, filters, settings, consent rows, and multi-select lists, and it composes into
other components — Card exposes it through its CornerSlot.

The control is binary: selected or unselected. Each Checkbox stands on its own, so several of them
in a group are independent — a control where exactly one option out of several can be chosen is
Radio, not Checkbox.

---

## 2. Anatomy

```
Checkbox                    .checkbox        s48 x s48 touch target — required
└── Box                     .checkbox__box   s24 x s24 visible box — required
    └── Check               .checkbox__check check glyph — required, hidden while unselected
```

| Element | Class | Required | Notes |
|---|---|---|---|
| Touch target | `.checkbox` | required | s48 x s48, centres the box; the focusable element |
| Box | `.checkbox__box` | required | s24 x s24, radius s8, s2 border |
| Check | `.checkbox__check` | required | `icons/outlined/actions/check.svg`, fills the box's inner area |

The s48 target is larger than the s24 box on purpose: the visible box carries the design, the
target carries the accessible hit area (WCAG 2.5.5). In Figma these are two components —
`.CheckNoSafezone` (node `4282:12760`) is the bare s24 box for cases where the parent already
provides the hit area, and `[Checkbox] 3.1` wraps it in the s48 safe zone.

---

## 3. Variants and sizes

Checkbox has one size. Its variability is the state matrix in section 4 — the Figma component set
exposes exactly two variant axes:

| Axis | Values | Default |
|---|---|---|
| `State` | `Unselected` · `Selected` | `Unselected` |
| `Disabled` | `false` · `true` | `false` |

| Node | Web | Purpose |
|---|---|---|
| `[Checkbox] 3.1` (`17855:6623`) | `.checkbox` | s48 touch target — the default consumers place |
| `.CheckNoSafezone` (`4282:12760`) | `.checkbox--no-safezone` | bare s24 box — for parents that own the hit area |
| `.checkbox-icon` (`4350:894`) | `.checkbox__check` | the check glyph itself |

### 3a. No safe zone

`.checkbox--no-safezone` collapses the s48 wrapper onto the s24 box. It is for a parent that
already provides the hit area and the accessible semantics — [Card](https://super-dollop-pzmo65r.pages.github.io/card.md)'s CornerSlot
is the reference consumer: the slot is the s48 target carrying `role="checkbox"`, `aria-checked`
and focus, and the Checkbox instance inside it is visual only (`aria-hidden="true"`).

The modifier changes size only. Every state selector still applies, because it stays on the
`.checkbox` element.

A bare box used **without** a parent hit area fails WCAG 2.5.5 — the s48 default exists for that
reason.

There is no indeterminate state in the Figma contract. It is deferred until the design defines it.

---

## 4. States

| State | Class | Box | Check |
|---|---|---|---|
| **Standard** | `.checkbox` | transparent, s2 border `TextAndIcon/Secondary` | hidden |
| **Selected** | `.checkbox--selected` | filled `TextAndIcon/Primary` | visible, `TextAndIcon/InversePrimary` |
| **Disabled** | `.checkbox--disabled` | transparent, s2 border `TextAndIcon/Disabled` | hidden |
| **Selected disabled** | `.checkbox--selected.checkbox--disabled` | filled `TextAndIcon/Disabled` | visible, `TextAndIcon/InversePrimary` |
| **Focus visible** | `:focus-visible` | s2 outline `TextAndIcon/Primary`, inset, radius s12 | unchanged |

The border colour matches the fill in both selected states, so the box keeps its s24 outer size
across the whole matrix — nothing shifts when the value changes.

---

## 5. Animation and behavior

Motion follows [`motion-rules.md`](https://super-dollop-pzmo65r.pages.github.io/motion.md) §6.6, which catalogues
Checkbox explicitly.

| Event | Pattern | Token | CSS |
|---|---|---|---|
| Unselected → Selected | `Transforming/State/Default` | `--checkbox-state-default` | `background-color`, `border-color`, `opacity` |
| Selected → Unselected | `Transforming/State/Default` | `--checkbox-state-default` | `background-color`, `border-color`, `opacity` |
| Disabled change | `Transforming/State/Default` | `--checkbox-state-default` | `border-color`, `background-color` |

```css
:root { --checkbox-state-default: 200ms cubic-bezier(0.4, 0, 0.2, 1); }
@media (prefers-reduced-motion: reduce) {
  :root { --checkbox-state-default: 0ms linear; }
}
```

The check appears by `opacity`, not by scale or path drawing — only compositor-friendly properties
animate. Reduced motion zeroes the token, so the state change becomes instant.

---

## 6. Color tokens

One token name per role; Light/Dark substitution happens by name.

| Element | Token |
|---|---|
| Box border — Standard | `var(--text-and-icon-secondary)` |
| Box fill — Selected | `var(--text-and-icon-primary)` |
| Box border — Selected | `var(--text-and-icon-primary)` |
| Box border — Disabled | `var(--text-and-icon-disabled)` |
| Box fill — Selected disabled | `var(--text-and-icon-disabled)` |
| Check glyph | `var(--text-and-icon-inverse-primary)` |
| Focus outline | `var(--text-and-icon-primary)` |

The disabled state is expressed by a token swap, never by `opacity`.

---

## 7. Typography

Checkbox carries no text of its own. A label sits beside it as a separate element — in a list row
that is [Cell](https://super-dollop-pzmo65r.pages.github.io/cell.md), whose own typography applies. When a bare label is paired with the
control, it uses **Main Body** and is associated with the input (see
[`checkbox-a11y.md`](https://super-dollop-pzmo65r.pages.github.io/checkbox.md)).

---

## 8. Spacing

| Property | Token | Value |
|---|---|---|
| Touch target | `var(--sp-s48)` | 48 x 48 |
| Touch target — no safe zone | `var(--sp-s24)` | 24 x 24 — collapsed onto the box; the parent owns the hit area |
| Box | `var(--sp-s24)` | 24 x 24 |
| Box radius | `var(--sp-s8)` | 8 |
| Box border width | `var(--sp-s2)` | 2 |
| Target padding | `var(--sp-s0)` | 0 — the box is centred by flex |
| Focus outline width | `var(--sp-s2)` | 2, inset |
| Focus outline radius | `var(--sp-s12)` | 12 — the ring follows the s48 target, not the s24 box |

The s12 gap between the target edge and the box is geometry, not padding: (48 − 24) / 2, produced
by centring. When Card places a Checkbox in its CornerSlot, the s8 slot padding lands the box s16
from both card edges (see [`card.md`](https://super-dollop-pzmo65r.pages.github.io/card.md) § CornerSlot).

---

## 9. Usage context

**When to use**

- A single option that is turned on or off (consent, "remember me", a setting).
- Any number of items selectable from a list, including none.
- A parent row that toggles a piece of content, via Card's CornerSlot.

**When not to use**

- Exactly one option out of several — that is Radio.
- An immediate on/off that applies without confirmation — that is Switch.
- A filter chip or removable token — that is [Tag](https://super-dollop-pzmo65r.pages.github.io/tag.md).

**Related components** — [Cell](https://super-dollop-pzmo65r.pages.github.io/cell.md) (list row that hosts a Checkbox),
[Card](https://super-dollop-pzmo65r.pages.github.io/card.md) (CornerSlot), Radio, Switch.

---

## 10. Accessibility

Full spec: [`checkbox-a11y.md`](https://super-dollop-pzmo65r.pages.github.io/checkbox.md).

The s48 target satisfies WCAG 2.5.5 Target Size — the s24 box alone does not, so the target is
always the focusable and clickable element, never the box. The control exposes
`role="checkbox"` with `aria-checked`, is reachable by Tab, and toggles on Space. State is never
communicated by colour alone: the check glyph is the non-colour signal.

---

## 11. Analytics and coverage contract

Contract per [`prototype-analytics-and-coverage.md`](https://github.com/inDriver/oymyakon-ds/blob/main/docs/prototype-analytics-and-coverage.md).

| Field | Value |
|---|---|
| `data-ds-component` | `checkbox` |
| Coverage unit | yes |
| Tap target model | root — the s48 target is the only target |
| Actions | `toggle` |
| Internal targets | none |
| Emits value | yes — `data-ds-state` reflects `selected` / `unselected` |

```html
<button class="checkbox checkbox--selected"
        data-ds-component="checkbox"
        data-ds-component-id="consent-marketing"
        data-ds-state="selected"
        data-ds-action="toggle"
        role="checkbox" aria-checked="true" aria-labelledby="consent-marketing-label">
  <span class="checkbox__box">
    <span class="checkbox__check"><!-- icons/outlined/actions/check.svg --></span>
  </span>
</button>
<span id="consent-marketing-label">Send me offers</span>
```

When a visible label sits beside the control, `aria-labelledby` points at it — that is preferred
over `aria-label`, which would duplicate the text into a second, unsynchronised place. Use
`aria-label` only when there is no visible label.

`data-ds-state` is updated together with `aria-checked` on every toggle, so the analytics value and
the accessible value never diverge.

---

## Changelog

| Version | Date | Change |
|---|---|---|
| 3.1.2 | 2026-08-04 | `type="button"` on the root, recorded in `checkbox-a11y.md` § Role and ARIA and added to all ten interactive checkboxes on the page — a bare `<button>` inside a form defaults to `submit`, so every activation would submit, including a TalkBack or VoiceOver double tap. RadioButton's spec carried this and Checkbox's did not. Also consumes `var(--transforming-state-default)` directly; `--checkbox-state-default` was a local alias the motion pipeline made redundant. |
| 3.1.1 | 2026-07-30 | Status Draft → Ready. Added `.checkbox--no-safezone` (§3a), the web counterpart of Figma `.CheckNoSafezone` — the bare s24 box for a parent that owns the hit area. Card's CornerSlot now consumes the component instead of composing the geometry page-locally. |
| 3.1.0 | 2026-07-29 | Initial spec, aligned to Figma `[Checkbox] 3.1` (node `17855:6623`): State x Disabled (4 states), s48 target over a s24 box, `Transforming/State/Default` motion. Indeterminate deferred — not in the Figma contract. |

---

# Checkbox — Accessibility

**Component:** Checkbox
**Version:** 3.1.1
**Spec:** [`checkbox.md`](https://super-dollop-pzmo65r.pages.github.io/checkbox.md)

---

## Role and ARIA

| Attribute | Value | Where |
|---|---|---|
| `type` | `"button"` | The s48 target — a bare `<button>` in a form defaults to `submit` |
| `role` | `"checkbox"` | The s48 target (`.checkbox`) |
| `aria-checked` | `"true"` / `"false"` | The s48 target — mirrors the selected state |
| `aria-label` | The option's text | The s48 target, when no visible label is associated |
| `aria-labelledby` | id of the visible label | Preferred over `aria-label` when a label is on screen |
| `aria-disabled` | `"true"` | The s48 target, when disabled |

A native `<input type="checkbox">` carries the role and checked state on its own. When the control
is built from a `<button>` (the DS markup, so the box can be styled), `role` and `aria-checked` are
required — a `<button>` alone announces as a button and exposes no value.

The button also carries `type="button"`. Without it a `<button>` inside a `<form>` defaults to
`type="submit"`, so every activation would submit the form — including a TalkBack or VoiceOver
double tap, which activates the control through a synthesized `click` rather than a key press. That
would contradict the keyboard model below, where submitting is reserved for `Enter`.

`aria-checked` and `data-ds-state` are updated in the same handler, so the accessible value and the
analytics value never drift apart.

---

## Focus

The **s48 target** is the focusable element, never the s24 box — focusing the box would give a
24 x 24 hit area and fail WCAG 2.5.5 Target Size (minimum 24 x 24, with 44 x 44 recommended for
pointer input; the DS uses 48).

| Requirement | Implementation |
|---|---|
| Reachable by Tab | `<button>` is focusable natively; a custom element needs `tabindex="0"` |
| Visible focus ring | `:focus-visible` — s2 inset outline in `var(--text-and-icon-primary)`, radius s12 |
| Disabled | Not focusable — `disabled` on the button, or `aria-disabled` + removed from tab order |

The focus ring is inset so it stays inside the s48 target and never collides with adjacent
controls in a dense list.

---

## Keyboard

| Key | Action |
|---|---|
| `Tab` / `Shift+Tab` | Move focus to / from the checkbox |
| `Space` | Toggle between selected and unselected |
| `Enter` | Nothing — reserved for submitting the surrounding form |

`Enter` does not toggle a checkbox — that is the platform convention, and it keeps `Enter` free to
submit the surrounding form.

**Implementation note.** Because the DS markup is a real `<button>` (chosen so the box can be
styled), the browser synthesizes a `click` when `Enter` is pressed on it. A bare `click` handler
would therefore toggle on `Enter`, contradicting the rule above. The handler flags `Enter` on
`keydown` and ignores the click it produces:

```js
let viaEnter = false;
cb.addEventListener('keydown', function (e) {
  if (e.key === 'Enter') { viaEnter = true; return; }
  if (e.key === ' ' || e.key === 'Spacebar') { e.preventDefault(); toggle(cb); }
});
cb.addEventListener('click', function () {
  if (viaEnter) { viaEnter = false; return; }
  toggle(cb);
});
```

A native `<input type="checkbox">` needs none of this — it ignores `Enter` on its own. The guard is
the cost of styling the box.

---

## Contrast

| Pair | Requirement |
|---|---|
| Box border (Standard) vs background | ≥ 3:1 — non-text UI component contrast (WCAG 1.4.11) |
| Box fill (Selected) vs background | ≥ 3:1 |
| Check glyph vs box fill | ≥ 3:1 |
| Focus outline vs background | ≥ 3:1 |

`TextAndIcon/Disabled` is deliberately below the contrast floor: a disabled control is exempt from
1.4.11. Because of that, disabled state is never the only way information is conveyed.

State is never signalled by colour alone — the **check glyph** is the non-colour indicator that
distinguishes selected from unselected.

---

## Android · TalkBack

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| Checkbox (unselected) | Option text | "Not checked" | Checkbox | "Double tap to toggle" |
| Checkbox (selected) | Option text | "Checked" | Checkbox | "Double tap to toggle" |

### Edge states

- **Disabled:** announced as "Disabled" after the label; the control is skipped in swipe navigation and does not respond to double tap.
- **Selected disabled:** announces the label, "Checked", then "Disabled" — the value is still read, so the person knows what state is frozen.
- **In a Cell row:** the row and the checkbox are one focus stop, announced as a single checkbox with the row's text as the label — not two separate stops.

---

## iOS · VoiceOver

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| Checkbox (unselected) | Option text | — | Button, not selected | "Double tap to toggle setting" |
| Checkbox (selected) | Option text | — | Button, selected | "Double tap to toggle setting" |

iOS has no native checkbox trait: the state is carried by the **selected** trait
(`accessibilityTraits.selected`) rather than by a value string, which is what VoiceOver users
expect on the platform.

### Edge states

- **Dimmed:** `accessibilityTraits.notEnabled` — VoiceOver appends "Dimmed"; the control stays in the rotor so its state is discoverable.
- **Selected dimmed:** announces the label, "Selected", then "Dimmed".
- **In a Cell row:** the row is one accessibility element; the checkbox's state is merged into the row's traits instead of being a separate element.

---

## Testing checklist

- [ ] Tab reaches the checkbox; the focus ring is visible against both Light and Dark backgrounds
- [ ] Space toggles; `Enter` does not
- [ ] `aria-checked` flips with the visual state on every toggle
- [ ] The full s48 target is clickable, not only the s24 box
- [ ] TalkBack announces label + "Checked" / "Not checked"
- [ ] VoiceOver announces label + the selected trait
- [ ] Disabled controls are not reachable by Tab and do not respond to Space
- [ ] Selected state is distinguishable with colour filters on (the glyph carries it)

---

## Machine contract — `specs/components/checkbox/capabilities.json`

Axes, allowed values, defaults, constraints and CSS/Figma bindings. Read this instead of guessing what the component can do.

```json
{
  "$schema": "../component-capabilities.schema.json",
  "id": "checkbox",
  "name": "Checkbox",
  "version": "3.1.2",
  "description": "Binary selection control: a s24 box carrying a check glyph when selected, wrapped in a s48 touch target. One size; variability is the State x Disabled matrix.",
  "files": {
    "spec": "specs/components/checkbox/checkbox.md",
    "a11y": "specs/components/checkbox/checkbox-a11y.md",
    "preview": "src/checkbox.njk",
    "css": "src/shared/shared.css"
  },
  "figma": {
    "library": "🕹️ Oymyakon 3.28.0 (components)",
    "fileKey": "7vdl5YkZFDWvh9QvSmydsH",
    "componentSets": {
      "[Checkbox] 3.1": {
        "key": "969e9aae388c13752b60204ff1462b7f4693db25",
        "note": "The s48 touch target — what consumers place. Node 17855:6623."
      },
      ".CheckNoSafezone": {
        "key": "8cac2aac1dfebc4a12c06e9e6fa551741e1d322b",
        "note": "The bare s24 box, no safe zone — for parents that already own the hit area. Node 4282:12760."
      }
    },
    "capturedAt": "2026-07-29"
  },
  "root": {
    "class": "checkbox",
    "dataDsComponent": "checkbox"
  },
  "anatomy": {
    "box": {
      "class": "checkbox__box",
      "notes": "Figma .CheckNoSafezone. s24 x s24, radius s8, s2 border; centred inside the s48 target by flex, so the s12 inset is geometry rather than padding."
    },
    "check": {
      "class": "checkbox__check",
      "notes": "Figma .checkbox-icon (component key 9d8fd815fc2c25069022d0d1c401ca7ba8f48c99). The DS icons/outlined/actions/check.svg glyph; fills the box inner area at native 24-grid proportions and appears by opacity. Hidden while unselected."
    }
  },
  "axes": {
    "selected": {
      "title": "Selected",
      "type": "boolean",
      "default": false,
      "css": {
        "modifier": ".checkbox--selected"
      },
      "figma": {
        "kind": "variant-property",
        "property": "State",
        "values": {
          "false": "Unselected",
          "true": "Selected"
        }
      },
      "notes": "Selected fills the box with --text-and-icon-primary and reveals the check glyph. The border colour matches the fill so the box keeps its s24 outer size across the matrix."
    },
    "safeZone": {
      "title": "Safe zone",
      "type": "boolean",
      "default": true,
      "css": {
        "mechanism": "true = bare .checkbox (s48 target); false = .checkbox--no-safezone, which collapses the wrapper onto the s24 box"
      },
      "figma": {
        "kind": "component-set",
        "values": {
          "true": "[Checkbox] 3.1",
          "false": ".CheckNoSafezone"
        },
        "notes": "Two separate Figma components rather than a variant property on one set."
      },
      "constraints": [
        "false is allowed ONLY when the parent supplies the s48 hit area and the accessible semantics (role, aria-checked, focus) — Card's CornerSlot is the reference consumer. A bare box on its own fails WCAG 2.5.5.",
        "The instance inside such a parent is visual only and carries aria-hidden=\"true\", so the control is announced once, by the parent."
      ]
    },
    "disabled": {
      "title": "Disabled",
      "type": "boolean",
      "default": false,
      "css": {
        "modifier": ".checkbox--disabled"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Disabled",
        "values": {
          "false": "false",
          "true": "true"
        }
      },
      "notes": "Expressed by a colour-token swap to --text-and-icon-disabled, never by opacity. Combines with selected for the fourth state."
    }
  },
  "states": {
    "default": [
      "standard",
      "selected",
      "disabled",
      "selected-disabled",
      "focus-visible"
    ]
  },
  "constraints": [
    "The s48 target is the focusable and clickable element, never the s24 box — focusing the box alone would give a 24 x 24 hit area and fail WCAG 2.5.5 Target Size.",
    "There is no indeterminate state: it is absent from the Figma contract ([Checkbox] 3.1 exposes only State x Disabled). Deferred until the design defines it — do not invent one.",
    "Checkbox carries no text of its own. A label is a separate element beside it; in a list row that element is Cell.",
    "Disabled is a token swap, never an opacity multiplier (component-authoring rule).",
    "State is never signalled by colour alone — the check glyph is the non-colour indicator.",
    "aria-checked and data-ds-state are written in the same handler so the accessible value and the analytics value cannot drift apart.",
    "Space toggles; Enter does not, so Enter stays free to submit the surrounding form.",
    "One size only. The s24 box and s48 target are fixed — a smaller checkbox is not a supported customization.",
    "Motion is the catalogued Transforming/State/Default (motion-rules.md §12) in both directions — var(--transforming-state-default), declared DS-wide in tokens/generated/motion.css and zeroed there under prefers-reduced-motion.",
    "The root carries type=\"button\". Without it a <button> inside a <form> defaults to type=\"submit\", so every activation submits — including a TalkBack or VoiceOver double tap, which fires a synthesized click rather than a key press."
  ],
  "analytics": {
    "dataDsComponent": "checkbox",
    "action": "toggle",
    "valueAttr": "data-ds-state",
    "notes": "Coverage unit; the root s48 target is the only tap target and there are no internal targets. data-ds-state carries selected | unselected."
  },
  "rtl": {
    "supported": true,
    "notes": "The control is symmetric — the box carries no directional content, so no mirroring is needed. Its position relative to a label follows the parent's writing direction."
  }
}
```
