Radio

Radio lets people pick exactly one option from a group. Use it when the options are few and all should be visible.

  • Beta
  • Accessibility
  • Figma Radio Button 4457:59936
  • packages/ui/src/components/Radio

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.

Who can see this lesson?

Keep the lesson as a draft while you work on it.

Everyone enrolled in Biology 101 can open it.

Share it with students outside the space.

tsx
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

bash
pnpm add @nuton/ui @nuton/tokens

Peer 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

tsx
// 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

tsx
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 one name and gives you native arrow-key movement.
  • Control it with value and onValueChange, or leave it uncontrolled with defaultValue. Each Radio needs a value.
  • Name the group: label (the legend), a surrounding Field, or aria-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

Lesson length
vertical · gap 12
Lesson length
horizontal · gap 16, wraps

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

AI tutor feedback

The tutor nudges students without giving the answer.

New
  • sublabel is part of the accessible name; description is linked with aria-describedby.
  • badge and action render outside the <label>: not part of the name, and the action is never nested in it.
  • flip moves the control to the trailing edge. Use it for every option in a group or none.

Sizes

PartSizeToken or source
Control frame20 × 20--nu-space-5
Circle16 × 16 (2px inset)--nu-space-4, --nu-radius-full
Unselected inner circle13 × 13, shows a 1.5px ringComponent value (Figma geometry)
Selected dot8 × 8--nu-space-2
Pointer target24 × 24 (invisible native input, 2px beyond the frame)--nu-space-0-5
Label rowGap 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 descriptionParagraph/Small 14/24, weight 400 (24px row)--nu-type-paragraph-small-*
Label text, with descriptionLabel/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

DefaultUnselected shows a State/Faded/Base ring (C48); selected fills State/Primary/Base with an 8px white dot.
HoverPoint at these. Unselected turns Sub-300, selected turns blue-800. Hovering the label has the same effect. Only on hover-capable pointers.
FocusThe ring turns primary (selected: State/Primary/Dark) and the primary focus halo is added, below 3:1 today (C5).
DisabledSoft-200 with no inner circle; selected keeps the white dot. Disable the whole group, or one option with disabled on the Radio.
Error (code-only)Figma has no error state. Code turns unselected rings State/Error/Base and sets aria-invalid on the group. Needs design confirmation (pending a decision).

Turned off by your school

Disabled label (code-only)Figma has no disabled label style. Code uses Text/Disabled-500 (4.23:1, C8).

State tokens

Set on .nu-radio. Override them on a wrapper only for a documented exception.

TokenValueUsed for
--nu-radio-bgState/Faded/Base (Figma Bg/Soft-200, C48)Unselected ring
--nu-radio-bg-hoverIcon/Sub-600 (Figma Bg/Sub-300, C48)Unselected, hover
--nu-radio-bg-checkedState/Primary/BaseSelected fill
--nu-radio-bg-checked-hoverFoundation blue-800Selected, hover
--nu-radio-bg-focusState/Primary/BaseUnselected, focus
--nu-radio-bg-checked-focusState/Primary/Dark (blue-900)Selected, focus
--nu-radio-bg-invalidState/Error/BaseUnselected, invalid (code-only)
--nu-radio-bg-disabledBg/Soft-200Disabled
--nu-radio-dotBg/WhiteSelected dot
--nu-radio-focus-shadowbuttons/primary-focusFocus halo (C5)
--nu-radio-inner-shadowregular-shadow/x-smallInner 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.

Question 3 of 10

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.

Lesson pace

Shorter sections with a recap after each one.

The pace most students in this space choose.

New

Fewer recaps and harder practice questions.

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.

Who can join Biology 101?

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

RadioGroup props
PropTypeDefaultDescription
childrenrequiredReactNode—The Radio options.
labelReactNode—Visible group label, rendered as the <legend>. Leave it out inside a Field.
namestringautoShared name of the radios (the form field name). Arrow keys move between radios that share it.
valuestring—Selected value (controlled).
defaultValuestring—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).
disabledbooleanfalse (or from Field)Disables every radio (native fieldset disabled).
requiredbooleanfrom FieldNative required on every radio and aria-required on the group.
invalidbooleanfalse (or from Field)aria-invalid on the group and error rings on unselected radios. Inside a Field, it comes from error. Code-only state.
idstringauto (or from Field)Id of the fieldset. The legend id derives from it.
aria-labelledbystring—Overrides the legend or Field label as the group name.
aria-describedbystring—Merged with the Field hint and error ids.
classNamestring—Class for the <fieldset>.
refRef<HTMLFieldSetElement>—Forwarded to the fieldset.
…restOmit<FieldsetHTMLAttributes, 'onChange' | 'defaultValue' | 'children' | 'name'>—Any other fieldset attribute (aria-label, form, data-*…).

Radio props

Radio props
PropTypeDefaultDescription
valuerequiredstring—This option's value, compared with the group's value or defaultValue.
labelReactNode—Visible label, rendered as a native <label>. Without it, only the 20px control renders and it needs aria-label.
sublabelReactNode—Secondary text after the label. Part of the accessible name. No default text. Needs label.
descriptionReactNode—Second line under the label, linked with aria-describedby. Switches the label to Label/Small. Needs label.
badgeReactNode—Element after the label, outside the <label>. Needs label.
actionReactNode—Focusable, labelled element under the text. Never nested in the label. Needs label.
flipbooleanfalsePuts the control after the text. Needs label.
invalidbooleanfrom the groupError ring while unselected. Code-only state.
disabledbooleanfrom the groupDisables this option only.
requiredbooleanfrom the groupNative required.
namestring—Only outside a RadioGroup; inside, the group’s name is used.
checked / defaultCheckedboolean—Only outside a RadioGroup. Inside, the group's value / defaultValue decide.
idstringautoId of the native input.
aria-describedbystring—Merged with the description id.
classNamestring—Class for the outer element: the label layout when label is set, otherwise the 20px control.
refRef<HTMLInputElement>—Forwarded to the native input.
…restOmit<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

KeyBehaviour
TabMoves 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.
SpaceSelects 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 propertyFigma valuesCode
ActiveOff · OnThe group's value / defaultValue (standalone: checked)
StateDefault · Hover · Focused · DisabledInteractive states and disabled, plus invalid (code-only)
Edit LabelTextlabel
Sublabel · Edit SublabelBoolean · textsublabel
Description · Edit DescriptionOff · On · textdescription
BadgeBooleanbadge (pass a Badge)
Link ButtonBooleanaction (pass a LinkButton)
FlipOff · Onflip
(none)No group componentRadioGroup 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

Do and don't

Quiz attempts
DoStack the options. A vertical list is quick to scan and keeps each label next to its radio.
Quiz attempts
Don'tDon't run long options in a row. When a row wraps, it's hard to tell which radio belongs to which label.
Who can see this lesson?
DoName the group with a question. The legend is announced with each option, so it gives the choice its context.
Don'tDon't leave the group unnamed. Screen reader users hear “Students in this space, radio button” with no question.
Certificate
DoOffer at least two options. A radio can't be cleared once chosen, so give people the other answer, or use a single checkbox.
Certificate
Don'tDon't use one radio on its own. With nothing to switch to, it can't be undone.

Common mistakes

  • Rendering radios without a RadioGroup and forgetting a shared name, so arrow keys and single selection break.
  • Setting checked on a Radio inside a group. The group's value wins; set it there.
  • Giving the group a label inside 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.