Radio
Radio lets people pick exactly one option from a group. Use it when the options are few and all should be visible.
Beta until the focus indicator (C5) is resolved, and design confirms the code-only RadioGroup, error and disabled-label styles. Everything else on this page is implemented and tested.
Preview
Change the controls to try every supported combination. The preview is a real, controlled RadioGroup: choose an option and the value control follows, and the code updates with it.
import { Radio, RadioGroup } from '@nuton/ui'
const [value, setValue] = useState('space')
<RadioGroup label="Who can see this lesson?" value={value} onValueChange={setValue}>
<Radio value="private" label="Only me" description="Keep the lesson as a draft while you work on it." />
<Radio value="space" label="Students in this space" description="Everyone enrolled in Biology 101 can open it." />
<Radio value="link" label="Anyone with the link" description="Share it with students outside the space." />
</RadioGroup>Installation
Availability
@nuton/ui and @nuton/tokens are private workspace packages. They aren't published to a registry yet (D4: registry deferred; D12: distribution waits on the provenance review). Inside the Nuton monorepo, add them as workspace dependencies. The commands below apply once the packages are published.
1. Add the packages
pnpm add @nuton/ui @nuton/tokensnpm install @nuton/ui @nuton/tokensyarn add @nuton/ui @nuton/tokensPeer dependencies: react and react-dom 18 or later. Add @remixicon/react only if you pass icons into a badge or action (Remix Icon is Nuton's icon set, D1).
2. Load tokens and component styles once
// app/layout.tsx (or your app's entry file)
import '@nuton/tokens/tokens.css' // --nu-* design tokens
import '@nuton/ui/styles.css' // component styles (uses the tokens)Labels use --nu-font-body (Inter). Map the token to your loaded font as described on the Button page. Radio's label layout styles ship in the same stylesheet as Checkbox's, so import the whole styles.css rather than picking files.
Usage
import { Radio, RadioGroup } from '@nuton/ui'
export function LessonVisibility() {
return (
<RadioGroup label="Who can see this lesson?" name="visibility" defaultValue="space">
<Radio value="private" label="Only me" />
<Radio value="space" label="Students in this space" />
<Radio value="link" label="Anyone with the link" />
</RadioGroup>
)
}- Always put radios in a
RadioGroup. It renders a<fieldset role="radiogroup">, shares onenameand gives you native arrow-key movement. - Control it with
valueandonValueChange, or leave it uncontrolled withdefaultValue. EachRadioneeds avalue. - Name the group:
label(the legend), a surrounding Field, oraria-label. Development warnings flag a missing group or option name. - Preselect the safest or most common option, so the group is never empty unless a choice must be deliberate (a quiz answer).
- Keep options short, parallel and in a logical order. Use 2–5 options; for more, use a Select.
Variants and sizes
Radio has one size. The group sets the orientation; each option can carry the text that Figma's Radio Label defines.
Orientation
vertical · gap 12horizontal · gap 16, wraps<RadioGroup label="Lesson length" defaultValue="15">
<Radio value="10" label="10 minutes" />
<Radio value="15" label="15 minutes" />
<Radio value="30" label="30 minutes" />
</RadioGroup>
<RadioGroup label="Lesson length" orientation="horizontal" defaultValue="15">
<Radio value="10" label="10 minutes" />
<Radio value="15" label="15 minutes" />
<Radio value="30" label="30 minutes" />
</RadioGroup>Vertical is the default and the easiest to scan. Use horizontal only for two or three short options; it wraps on narrow screens.
Label options
<RadioGroup label="AI tutor feedback" defaultValue="hints">
<Radio value="hints" label="Hints only" description="The tutor nudges students without giving the answer." />
<Radio value="worked" label="Worked examples" sublabel="(Recommended)" />
<Radio
value="voice"
label="Spoken feedback"
badge={<Badge size="sm" color="blue" appearance="light">New</Badge>}
action={<LinkButton href="/help/spoken-feedback" tone="primary" size="sm">Try a sample</LinkButton>}
/>
<Radio value="off" label="No feedback" flip />
</RadioGroup>sublabelis part of the accessible name;descriptionis linked witharia-describedby.badgeandactionrender outside the<label>: not part of the name, and the action is never nested in it.flipmoves the control to the trailing edge. Use it for every option in a group or none.
Sizes
| Part | Size | Token or source |
|---|---|---|
| Control frame | 20 × 20 | --nu-space-5 |
| Circle | 16 × 16 (2px inset) | --nu-space-4, --nu-radius-full |
| Unselected inner circle | 13 × 13, shows a 1.5px ring | Component value (Figma geometry) |
| Selected dot | 8 × 8 | --nu-space-2 |
| Pointer target | 24 × 24 (invisible native input, 2px beyond the frame) | --nu-space-0-5 |
| Label row | Gap 8 to the text, control top-aligned; 4 between label and description; 10 before the action | --nu-space-2, -1, -2-5 |
| Label text, no description | Paragraph/Small 14/24, weight 400 (24px row) | --nu-type-paragraph-small-* |
| Label text, with description | Label/Small 14/20, weight 500. Description: Paragraph/X Small 12/16 | --nu-type-label-small-*, --nu-type-paragraph-x-small-* |
| Group (code-only) | Legend Label/Small with 12 below; options gap 12; horizontal column gap 16 | --nu-space-3, --nu-space-4 |
Values from the component CSS, which follows Figma's geometry (Radio [1.1] 4457:59937, Radio Label [1.1] 4457:59961). Figma has one size and defines no group.
States and interactions
disabled on the Radio.aria-invalid on the group. Needs design confirmation (pending a decision).State tokens
Set on .nu-radio. Override them on a wrapper only for a documented exception.
| Token | Value | Used for |
|---|---|---|
--nu-radio-bg | State/Faded/Base (Figma Bg/Soft-200, C48) | Unselected ring |
--nu-radio-bg-hover | Icon/Sub-600 (Figma Bg/Sub-300, C48) | Unselected, hover |
--nu-radio-bg-checked | State/Primary/Base | Selected fill |
--nu-radio-bg-checked-hover | Foundation blue-800 | Selected, hover |
--nu-radio-bg-focus | State/Primary/Base | Unselected, focus |
--nu-radio-bg-checked-focus | State/Primary/Dark (blue-900) | Selected, focus |
--nu-radio-bg-invalid | State/Error/Base | Unselected, invalid (code-only) |
--nu-radio-bg-disabled | Bg/Soft-200 | Disabled |
--nu-radio-dot | Bg/White | Selected dot |
--nu-radio-focus-shadow | buttons/primary-focus | Focus halo (C5) |
--nu-radio-inner-shadow | regular-shadow/x-small | Inner circle shadow (nearest token) |
Figma discrepancies (pending a decision)
- Radio Label without a description uses Paragraph/Small (24px line, regular weight); with a description, and in Checkbox Label, it is Label/Small (20px). The radio is top-aligned in the 24px row, so it sits 2px above the text's centre. Code follows Figma; the proposed fix is Label/Small with a 20px row.
- Hover and focus colours differ from Checkbox's: Radio selected-hover is blue-800 and selected-focus blue-900; Checkbox uses blue-700 and blue-800. Proposal: align both.
- The inner circle's shadow (0 2 4 −2, 12%) has no matching token; the nearest,
regular-shadow/x-small, is used. - Figma defines single radios, not a group. The legend style and the 12px gap are code-only and need design confirmation.
Composition examples
Quiz question in a Field
A Field names the group with its label and links its hint and error to the group. Nothing is preselected, because the student must choose. Submit without an answer to see the error and focus move into the group.
Inside a Field, leave out the group's label: the Field label names it. The group takes invalid, disabled and required from the Field.
Setting with details
Descriptions explain each option. A badge flags something new, and an action under the text opens more detail without selecting the option.
In a native form
The radios are native inputs that share the group's name, so an uncontrolled group submits its value with the form and required uses the browser's validation.
When to use something else
- More than five options, or little space: a Select.
- Switching a view (list or grid) where the change is immediate: Button group.
- Several options may be true at once: Checkbox.
Gap: Radio Card isn't built (pending a decision)
Figma has a Radio Card (4457:60026, 6 types × 4 states). It is outside the current API, and some types show brand or card-provider assets, which C15 and D12 rule out. Use a Radio with description until a card is designed for Nuton.
API reference
RadioGroup props
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | — | The Radio options. |
label | ReactNode | — | Visible group label, rendered as the <legend>. Leave it out inside a Field. |
name | string | auto | Shared name of the radios (the form field name). Arrow keys move between radios that share it. |
value | string | — | Selected value (controlled). |
defaultValue | string | — | Initially selected value (uncontrolled). |
onValueChange | (value: string) => void | — | Called with the newly selected value. |
orientation | 'vertical' | 'horizontal' | 'vertical' | Stacked, or a wrapping row. Gap 12 (16 between columns). |
disabled | boolean | false (or from Field) | Disables every radio (native fieldset disabled). |
required | boolean | from Field | Native required on every radio and aria-required on the group. |
invalid | boolean | false (or from Field) | aria-invalid on the group and error rings on unselected radios. Inside a Field, it comes from error. Code-only state. |
id | string | auto (or from Field) | Id of the fieldset. The legend id derives from it. |
aria-labelledby | string | — | Overrides the legend or Field label as the group name. |
aria-describedby | string | — | Merged with the Field hint and error ids. |
className | string | — | Class for the <fieldset>. |
ref | Ref<HTMLFieldSetElement> | — | Forwarded to the fieldset. |
…rest | Omit<FieldsetHTMLAttributes, 'onChange' | 'defaultValue' | 'children' | 'name'> | — | Any other fieldset attribute (aria-label, form, data-*…). |
Radio props
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | This option's value, compared with the group's value or defaultValue. |
label | ReactNode | — | Visible label, rendered as a native <label>. Without it, only the 20px control renders and it needs aria-label. |
sublabel | ReactNode | — | Secondary text after the label. Part of the accessible name. No default text. Needs label. |
description | ReactNode | — | Second line under the label, linked with aria-describedby. Switches the label to Label/Small. Needs label. |
badge | ReactNode | — | Element after the label, outside the <label>. Needs label. |
action | ReactNode | — | Focusable, labelled element under the text. Never nested in the label. Needs label. |
flip | boolean | false | Puts the control after the text. Needs label. |
invalid | boolean | from the group | Error ring while unselected. Code-only state. |
disabled | boolean | from the group | Disables this option only. |
required | boolean | from the group | Native required. |
name | string | — | Only outside a RadioGroup; inside, the group’s name is used. |
checked / defaultChecked | boolean | — | Only outside a RadioGroup. Inside, the group's value / defaultValue decide. |
id | string | auto | Id of the native input. |
aria-describedby | string | — | Merged with the description id. |
className | string | — | Class for the outer element: the label layout when label is set, otherwise the 20px control. |
ref | Ref<HTMLInputElement> | — | Forwarded to the native input. |
…rest | Omit<InputHTMLAttributes, 'type' | 'size' | 'children' | 'value'> | — | Any other native input attribute (onChange, aria-label, autoFocus…), spread on the input. |
Styling hooks
The group renders .nu-radio-group (with data-orientation and data-disabled), .nu-radio-group__legend and .nu-radio-group__items. Each control is .nu-radio (with data-invalid and data-disabled); with a label, the layout is .nu-choice with data-kind="radio", shared with Checkbox. Use props rather than styling these; the --nu-radio-*tokens (see State tokens) are the supported override points.
Accessibility
Keyboard
| Key | Behaviour |
|---|---|
| Tab | Moves focus into the group once: to the selected radio, or the first one when none is selected. Tab again leaves the group. |
| ↓ / → | Selects the next radio, wrapping at the end (native behaviour). Disabled options are skipped. |
| ↑ / ← | Selects the previous radio. |
| Space | Selects the focused radio if none is selected. |
Screen readers
The group is a radiogroup named by its legend, Field label or aria-label, with required and invalid state. Each option is a native radio, announced with its position (“2 of 3”). The circle is hidden.
Focus
The ring turns primary and the primary focus halo is added. The halo measures about 1.1:1 against white, below 3:1 (C5). Without the halo, a focused unselected radio would look selected.
Contrast and colour
The unselected ring is State/Faded/Base, 4.23:1 against white, and its hover Icon/Sub-600, 6.69:1, both above WCAG 1.4.11's 3:1 (C48; Figma's Soft-200 was 1.19:1). The error ring is 4.15:1 (C6). The disabled selected dot is 1.19:1 and disabled text 1.53:1 (C8). Labels are 17.93:1 and descriptions 6.69:1.
Touch and pointer
The invisible native input is 24 × 24, meeting WCAG 2.5.8 without a label. With a label, the whole label is a target too. Hover styles apply only on hover-capable pointers.
You must: put radios in a named RadioGroup; give every option a visible label (or aria-label); say why the group is invalid in text, such as a Field error, not only with red rings.
The sublabel is spaced from the label with a margin, not a space character. Test environments compute “Worked examples (Recommended)”, but some browsers may join the two words in the accessible name (pending a decision).
Figma mapping
| Figma property | Figma values | Code |
|---|---|---|
| Active | Off · On | The group's value / defaultValue (standalone: checked) |
| State | Default · Hover · Focused · Disabled | Interactive states and disabled, plus invalid (code-only) |
| Edit Label | Text | label |
| Sublabel · Edit Sublabel | Boolean · text | sublabel |
| Description · Edit Description | Off · On · text | description |
| Badge | Boolean | badge (pass a Badge) |
| Link Button | Boolean | action (pass a LinkButton) |
| Flip | Off · On | flip |
| (none) | No group component | RadioGroup with label and orientation (code-only) |
Guidelines and related components
Use Radio for
- Choosing exactly one of 2–5 options that people should compare side by side.
- Settings where each option needs a short explanation.
- Single-answer quiz questions.
Don't use Radio for
- Choosing several options. Use Checkbox.
- A long list. Use a Select.
- Switching a view instantly. Use Button group or Tabs.
Do and don't
Common mistakes
- Rendering radios without a
RadioGroupand forgetting a sharedname, so arrow keys and single selection break. - Setting
checkedon a Radio inside a group. The group'svaluewins; set it there. - Giving the group a
labelinside a Field, so it is named twice. - Selecting an option on hover or focus without the user choosing it, or triggering a page change on selection.