Components/Form
Radio
Radios let a reader pick exactly one option from a group. Choosing an option clears whichever was chosen before, so the group always holds a single answer. They suit a question whose options should all stay visible while the reader decides between them.
import { Radio } from '@growth-protocol-ai/alpha';When to use
Reach for a group of radios when a question has one answer, a handful of options, and the reader is better off weighing them against each other than recalling them. Every option stays on the page, which is what the vertical room buys — so write the options as answers to a question the group states above them.
When not to use
A question that can take more than one answer is a set of checkboxes, and so is a lone yes or no: two radios that are each other’s opposite say the same thing with twice the furniture. Once the options are values to be picked from rather than a choice to be weighed — a country, a currency, a long list — a dropdown asks it in one line. A setting that takes effect the moment it is touched is a toggle, not an answer to be submitted.
| State | When to use |
|---|---|
Unchosen | The resting box: --surface-depth-0 inside --border-default, under the soft inset shadow. Nothing about it is dimmed and there is deliberately no hover — an option nobody has picked yet is a question waiting, not a control switched off. |
Chosen | A --button-primary fill and the same checkmark a chosen checkbox draws, not a dot: the circle is the only cue that the choice is exclusive, and readers take that shape as “one of these” without being told. Choosing this one is what clears the rest, and the browser does it from the shared name. |
Ruled out | A --surface-disabled fill with the border kept and the text in --text-disabled — repainted rather than faded. The option is skipped by the keyboard and cannot be submitted, so reach for it where a choice exists but is not available to this reader. |
Locked answer | The same fill, and the checkmark survives at --border-light. A group nobody may change still has to say which way it was answered, which is the whole difference between showing a locked answer and hiding it. |
Write in flight | The box’s chrome gives way to a ring and the input switches itself off while it spins, with aria-busy set — so a second choice cannot be made while the first is still travelling. It is the answer that is pending, not the question, so spin the option that was picked rather than the group. |
Give every radio in a group the same name and a distinct value. That one attribute is what makes them exclusive, and it is also what gives the group arrow-key navigation and a single tab stop — behaviour no wrapper component here re-implements, because the browser already has it.
Name every option, and name the group too. The label prop names the option; the group needs a question of its own, which is a <fieldset> with a <legend> or an element referenced by aria-labelledby on a role="radiogroup" wrapper. A column of named options under no question is a list of answers to nothing.
Start a group answered. An unanswered group is a form that cannot tell “not yet” from “no”, and the keyboard cannot move through one without also answering it, because the arrow keys select as they move. Where none of the options is a safe default, add the one that says so — “No preference” — rather than leaving all of them clear.
Two options that are each other’s opposite are one checkbox, not two radios. Reach for a group at three choices, or at two that are not a yes and a no.
Disable an option to rule that one out, and a <fieldset disabled> to switch the whole question off. Setting the prop on every radio in turn looks the same and says more than it means — the question is unavailable, not each of its answers.
className and style dress the <label>; every other prop is the input’s. [data-slot="radio"] is the one selector into the component, and it is on the label, so it reaches the box and its text together.
Radio and Checkbox are the same control at two selection models, and they share a vocabulary down to the checkmark path. Change one and change the other — drift between them reads as a bug to everyone but the person who wrote it.
One piece of state for the group
checked with onChange, where the answer lives in your state rather than the DOM’s. Drive the whole group from a single value and compare it per option; a flag per radio is the version that ends up with two of them set. onChange fires on the radio being selected and never on the one being cleared, so event.target.value is the group’s new answer.
| Prop | Type | Default | Notes |
|---|---|---|---|
label | ReactNode | — | Text beside the box, in a span of the component’s own. Omit it and the box is unnamed until a <label htmlFor> of yours names it. |
name | string | — | What makes the group. Every radio carrying the same one answers the same question, and the browser clears the previous answer inside that name and nowhere else. |
value | string | number | — | The answer this option submits under the group’s name. Distinct on every radio in a group. |
defaultChecked | boolean | false | The option a group opens on when uncontrolled. Set it on one radio in the group, not none — see the guidelines. |
checked | boolean | — | Controlled selection. Pair it with onChange, and derive it from one value held for the whole group. |
onChange | (event: ChangeEvent<HTMLInputElement>) => void | — | Fires on the radio being selected, not on the one being cleared. Read event.target.value for the group’s new answer. |
disabled | boolean | false | Non-interactive, and repainted rather than faded. It keeps its checkmark. |
loading | boolean | false | A ring in place of the box, and the input disabled while it spins. Sets aria-busy. |
children | never | — | The text goes in label, which is what gives it a span the component can size and colour. |