Checkbox

Include this one. Each box answers for itself.

import { Checkbox } from "@dede-style/ui/checkbox", then <Checkbox checked={on} onChange={setOn} label="Accept the terms and conditions" />. It is controlled — the value lives with whoever owns it, and this renders it in a labelled row.

The whole row is the control. The label, the description and the space between them are one <label> wrapped around the box, so clicking anywhere on it toggles the box — which is what HTML does with a wrapping label, and costs no JavaScript. It is also why the label and the description are strings: a link inside a label toggles the control when clicked.

A checkbox collects an answer and waits. That is what separates it from a Switch, which takes effect the moment you move it. If ticking the box should do something immediately, it is a switch; if it is one of several things you are choosing before a submit, it is this.

A form’s worth

Each box is independent of its neighbours, which is what makes it not a radio.

A set of checkboxes is a Sleeve of them — one row per option, on the component scale, and nothing groups them beyond the rhythm. Tick two, tick none, tick all three: no row knows what the others are set to. A question with exactly one answer is a radio group, and it is a different component.

The name announced for each is the label alone. Without help, a wrapping label makes its entire text the control’s name, so the first row would be read as “Email me about this order Dispatch, delivery and any delays”. The input points aria-labelledby at the label and aria-describedby at the description, so a screen reader gets a name and then a description, in that order.

Mixed

Some but not all — a fact about a group, shown on the row that stands for it.

indeterminate is a display state, not a third value of checked. The two vary independently — a parent can be unchecked and mixed at once — so they are two props. The parent above is checked when both children are, mixed when one is, and plain when neither; ticking it sets both children to whatever it is not. Every one of those rules belongs to the form, and the component only displays the two values it is handed.

It is a DOM property with no attribute, so it is set through a ref rather than written as markup. The browser exposes it as aria-checked="mixed" on its own; the bar is the same weight and the same width as the tick, so the two marks read as one family.

When the label wraps

The box holds to the first line, however many follow.

The box centres on the label’s first line, not on the label. A row aligned to the middle of a three-line label puts the box beside the second line, where it reads as belonging to nothing in particular. The offset is (1lh − 20px) / 2 — one line of the label’s own type, less the box, halved — so it is right at every type size without a hand-derived number, and the description sits beneath the words rather than beneath the box.

This is the one rule the row exists to keep, and it is the row Radio will share: a different input in the same two columns, holding to the same line.

Disabled

Both states have to survive being dimmed.

Disabled is one opacity on the whole row — the box, the label and the description drop to 48% together, so a reader sees one thing set aside rather than three things at three strengths. Radio and Switch take the same value.

The pointer and the keyboard are both refused, the row’s cursor goes back to the arrow, and the hover wash does not appear — a disabled control must not offer feedback it will not honour.

Sizes

Three steps of the box; the words stay put.

16, 20 and 24 — size 4, 5 and 6, a --size-element-* step each. The mark follows at 80% of the box, the gap to the words is 8px at every step, and the label’s type does not move: the box centres on the first line whichever of the two is taller. 20 is the default; 16 is for a dense table cell, 24 for a touch-first form. A Radio takes the same three.