Components / forms
Choice
--se-choice-box, --se-choice-row and --se-switch-track were added rather than forcing the box onto --se-control-h, which would have made every checkbox 40px on the authority of a rule rather than a decision.
Summary
One component for checkbox, radio and switch — pick with kind.
| APG pattern | Checkbox, Radio Group, Switch |
| Built on | native elements |
| Status | stable |
| Since | 0.1.0 |
They share an anatomy and differ by one prop; splitting them would triplicate the markup to express a variant.
<Choice id="search" label="Show in search results" defaultChecked />
<Choice kind="checkbox" id="mixed" label="Some sections hidden" indeterminate />
<Choice kind="switch" id="domain" label="Custom domain" defaultChecked />
<Choice id="row-1" inline aria-label="Select Northlight" />
Anatomy
A <label data-se="choice"> wrapping a native <input>. The whole row is clickable because it is a label, not because anything listens for a click.
| Part | Element | Required |
|---|---|---|
control | <input> | yes |
description | <span> | no |
label | <span> | no |
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
kind | checkbox · radio · switch | checkbox | Which control this is. They share one anatomy and differ only here — switch for a setting that applies immediately, checkbox when there is a Save button. |
id | string | required | Wires the label to the input. Required, because a control with a visible label and no accessible name looks correct and is not. |
label | string | none | Visible label. Omit only for a cell control, and pass aria-label instead. |
description | React.ReactNode | none | Help text under the label, reaching the control through aria-describedby — so it is read as a description and not folded into the name. It sits inside the <label>, so clicking it toggles the control. That is deliberate: moving it out means the root stops being a <label>, which changes the anatomy for everyone. Avoid putting a link in it for the same reason. |
name | string | none | Required to group radios. |
indeterminate | boolean | none | Checkbox only — shows the minus mark. |
invalid | boolean | none | Sets data-invalid. Always pair with a message in words. |
inline | boolean | none | For a table cell or toolbar: fills its container instead of setting a row height. |
States
| State | Expressed as | |
|---|---|---|
| Checked | :checked | |
| Disabled | :disabled | |
| Hover | :hover | |
| Mixed | :indeterminate | |
| Disabled | data-disabled | |
| Invalid | data-invalid |
Checked and indeterminate come from the input, never from an attribute the caller writes.
Behavior
The platform's. Space toggles; a radio group's arrow keys and one-of-many constraint come free once every input shares a name.
Indeterminate is set through the DOM property, not an attribute — there is no indeterminate attribute in HTML, which is why the specimen sets it in a script.
appearance: none removes the platform control and keeps the contract. :checked, :indeterminate, :disabled and the whole keyboard model survive it — Space to toggle, arrows to move within a radio group. Nothing here re-implements any of them.
Accessibility
The label is part of the component. A Choice with no label and no aria-label is a control a screen reader can find and cannot describe.
invalid only alongside a visible message. The red border alone fails roughly one man in twelve.
The box stays 1.25rem at every density and the row carries the height — 1.75rem on a fine pointer, 2.75rem under (pointer: coarse). A checkbox that grew on touch would stop matching the type beside it, so this is the one place in the system where the target and the visible control are deliberately different sizes.
The touch floor applies only under (pointer: coarse). The box is not --se-control-h: that token is the height of a single-line control, where the height is the whole control, and a checkbox is a small box inside a taller row.
Keyboard
| Key | Result |
|---|---|
Space | Toggles the control |
Enter | Submits the form, where the control sits in one |
Roles, states and properties
| Attribute | Set by |
|---|---|
aria-label | set at render |
role | set at render |
Focus
The whole row is a <label>, so a click anywhere in it focuses the input. Focus is on the input, never the row.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It takes the touch floor under (pointer: coarse) — the visual size is unchanged on a desktop pointer.
Tested
test/apg/batch-d.test.jsxtest/browser/choice-sizing.spec.mjstest/browser/upstream-natives.spec.mjs
Known gaps
- 1 upstream ref — their checkbox controls a group of other checkboxes; ours is CheckboxGroup, which owns the relationship
Appearance
| Token | Used for |
|---|---|
--se-accent | background-color, border-color |
--se-border | border-color |
--se-border-hover | border-color |
--se-border-strong | border |
--se-border-width | border |
--se-choice-box | block-size, border-radius, inline-size |
--se-choice-row | min-block-size |
--se-duration-fast | transition |
--se-duration-instant | transition |
--se-ease-standard | transition |
--se-leading-body | line-height |
--se-leading-ui | line-height |
--se-negative | border-color |
--se-radius-pill | border-radius |
--se-space-1 | row-gap |
--se-space-2 | column-gap, gap |
--se-surface | background, background-color |
--se-surface-sunk | background-color |
--se-switch-track | inline-size |
--se-target-min | min-block-size |
--se-text | box-shadow |
--se-text-disabled | background, background-color, border-color, color |
--se-text-muted | background, color |
--se-text-on-intent | background, background-color, background-image |
--se-text-sm | font-size |
--se-text-xs | font-size |
Shape is the only thing separating the three, so it has to be unambiguous: a soft corner for a checkbox, --se-radius-pill for a radio and for the switch track.
The checkbox corner is a fraction of its own box, not a radius token. Every rectangular control in the system sits near 15% of its short side — the button and the input both reach that through an em-relative radius tied to their type step. A checkbox has no type step, and it used the small radius token — a fixed 5px against a 20px box. That is 25%, nearly twice as round as anything it sits beside, and it left 10px of straight edge on a 20px side. calc(var(--se-choice-box) * 0.16) scales with the thing it is drawn on, which is the same argument the button's radius makes. Recorded in ARCHITECTURE.md 2.17.
The mark is a masked pseudo-element built from the Phosphor check and minus at bold weight — an adornment inside a control, which is one of the three cases the weight rule sets aside — so it inherits nothing and needs no extra element inside a void tag.
The layer is drawn only when there is a mark, and that was the corner defect. It used to be present always, with mask: none when unchecked — and mask: none is not an empty mask, it is no masking, so the layer painted as a full opaque square. Every unchecked checkbox carried a square-cornered patch over its rounded interior: the radius was on the control and the thing covering it had none.
The switch's internal geometry — thumb inset, thumb size, travel — stays as frozen literals. They are derived from the box and the track, and giving each a token would add four names nobody would ever theme.
Motion
The switch thumb travels over --se-duration-instant. The checkbox mark does not animate: a tick that draws itself delays the confirmation the reader is waiting for.
Content
The label states what is true when it is on, not what the control does. Show in search results, not Toggle search visibility.
Never phrase it as a negative. Hide from search checked-off is two negatives to resolve.
Usage
kind="switch" only for settings that apply immediately. If there is a Save button, it is a checkbox — a switch that does not take effect until you save has lied about what it is.
Inside a table cell pass inline, so the control fills the cell rather than setting its own height.
When not to use it. Whenever several of these answer one question. One of many is a RadioGroup; any of many is a CheckboxGroup. Both own the fieldset and the legend, and CheckboxGroup also owns the tri-state parent that reports mixed. Neither is markup worth assembling by hand from these.
A single one is still right sometimes, which is why this is exported on its own: an I agree to the terms checkbox answers no group's question and needs no legend. The test is whether a legend would say anything the label does not.
Notes
Splitting into Checkbox, Radio and Switch was rejected. One anatomy, one stylesheet, one prop.
--se-choice-box, --se-choice-row and --se-switch-track were added rather than forcing the box onto --se-control-h, which would have made every checkbox 40px on the authority of a rule rather than a decision.