Forms
RadioGroup
A set of mutually exclusive choices.
Usage
import { RadioGroup } from '@misoto22/design'Notes
A set of mutually exclusive choices.
Radix owns the roving tabindex, so the whole group is ONE tab stop and the arrow keys move between options — which is what the ARIA radiogroup pattern requires and what a stack of hand-rolled <input type="radio"> wrappers usually gets wrong.
Inside a Field the group takes its name from that label, by pointing back at it: the root is a <div role="radiogroup"> and <label for> does not bind to one, so the words above it click through no more than a <legend> does. Standing alone, it still needs an aria-label of its own — an unnamed group is three unlabelled radios.
Anatomy
| Element | Description |
|---|---|
| Grouprequired | A <div role="radiogroup"> stacking its options. Being a div is why the label above it names the group by being pointed AT — aria-labelledby, not htmlFor — and why the words do not click through. |
| Rowrequired | The <label> RadioGroupItem wraps around control and words. It is the click target — a bare 18px circle is below every pointer-target guideline — and it is the only source of the option’s accessible name. |
| Circlerequired | The 18px control itself, its border turning --accent when chosen. |
| Dot | The 10px --accent fill inside the circle, present only on the chosen option. |
Best practices
Do
- Name the group. Inside a Field its label does it, through aria-labelledby; standing alone it needs its own aria-label, and without either the group is announced as three unlabelled radios.
- Set defaultValue or value. Selection follows focus here, so a group that starts empty commits an answer the moment anybody arrows into it — including a reader who was only passing through on the way to the next field.
- Add an explicit “None” or “Any” option when the answer is genuinely optional: there is no way back to nothing once a radio is chosen, neither by clicking it again nor from the keyboard.
Don’t
- Do not hang an expensive effect on onValueChange. Every arrow press commits, so a group whose options fetch or navigate fires once per key on the way past the ones nobody wanted.
- Do not reach past RadioGroupItem to the Radix primitive or hand-roll the row: selection-follows-focus is implemented in this item’s own focus handler, not upstream, so a hand-rolled one moves the outline and selects nothing.
- Do not disable one option to mean “not available here”: the roving focus skips it entirely, so a keyboard reader never learns the option exists. Say why in the Field’s hint and leave the option out.
Examples
default
Three options and one tab stop: the arrows move between them, which is the ARIA radiogroup pattern and the half a stack of hand-rolled radios usually loses. aria-label is not optional — the root is a div, so htmlFor, including the one a Field draws, binds to nothing and the group would be announced with no name at all. defaultValue matters as much: selection follows focus here, so a group that starts empty commits an answer on behalf of anyone who arrows past it on the way to the next field.
options with detail
A second line inside the option, where the price or the limit is what decides the choice. RadioGroupItem's own label wraps whatever it is given, so the detail is inside the click target and inside the accessible name — keep it to the one fact that settles it, because it is read out with the option every time. The rows are aligned to their first line, since a circle centred against two lines drifts away from the name it belongs to.
a way back
“Any time” is the option that keeps the rest optional. There is no way back to nothing once a radio is chosen — not by clicking it again, not from the keyboard — so a filter without an explicit escape is one a reader can enter and never leave. The same reasoning rules out disabling an option to mean “not available here”: the roving focus skips a disabled option entirely, so a keyboard reader never learns it exists. Leave it out and say why in the Field's hint.
Parts
Composed at the call site rather than configured through props, so a layout this component did not anticipate is still expressible.
RadioGroupItem
One option, and its label, as a single click target.
The <label> wraps both, so the whole row is clickable — a bare 18px circle is below every pointer-target guideline and is miserable on a phone. It is also what gives the control its accessible name: remove the wrapper and the radio has no name at all.
Selection follows focus, which is the half of the pattern that makes a radiogroup usable from the keyboard: moving to an option chooses it, so nobody has to press an extra key to commit. That is implemented here rather than inherited, because the upstream primitive gates it on a flag cleared by keyup and loses the race against its own focus move — see ARROW_GRACE_MS. Re-selecting an already-selected option is a no-op, so this stays correct even where the upstream path does fire.
| Prop | Type | Default | Description |
|---|---|---|---|
| childrenrequired | ReactNode | The visible label. Rendered inside the `<label>` that wraps the control. |
Also accepts everything in Omit<ComponentProps<typeof RadioGroupPrimitive.Item>, 'children'>. Those are forwarded to the underlying element and are not listed row by row.
Keyboard
| Key | Does |
|---|---|
| Tab | Moves into the group, and out of it — the whole group is one stop. |
| ↑↓←→ | Moves between options AND selects as it goes. |
Accessibility
- One tab stop for the whole group; the arrow keys move between options, per the ARIA radiogroup pattern.
- The label is inside the <label>, so the whole row is the click target.