Skip to content

Radio

Use a radio group when the choices are mutually exclusive and the user has to end up with exactly one of them. If they can pick several, use Checkbox; if there are more than about seven options, or the list is data rather than a decision, use Select.

radio/basic.tsx
Primary region

Radio renders a <span> with role="radio" and a hidden <input> next to it, so it carries no accessible name of its own — the Field.Label wrapped around it is what gives it one, and most examples on this page keep that pattern. A Field generates the ids, points each label at its own control and carries the name into form submission, so none of that appears in the demo code.

Import

import { Radio, RadioGroup } from "@forte-ui/react";

A Radio only means something inside a RadioGroup. The group owns the value, and it is what makes the set behave like a native radio group. A lone <Radio /> renders, but nothing can ever select it.

Examples

Sizes

size sets the painted circle — 0.875rem, 1rem or 1.25rem, matching Checkbox step for step so the two line up in a form that mixes them. The dot is a fraction of the circle rather than a fixed length, so it stays in proportion at every size and if you override --forte-radio-size to something off the scale. The fraction itself is retuned per size, because a dot under about 5px stops reading as a dot.

The clickable area does not shrink with the circle: a pseudo-element on the root holds the hit target at 24×24px minimum (WCAG SC 2.5.8), so a sm radio is no harder to hit than an lg one.

radio/sizes.tsx
Small
Medium
Large

Orientation

orientation="horizontal" lays the options out in a row instead of a column; horizontal groups wrap.

radio/orientation.tsx
Playback speed

This is layout only. It does not change the keyboard model — Base UI drives the group with all four arrow keys either way, so a horizontal group still answers to Up and Down, exactly as a native one does.

Descriptions

When the options need explaining, give each one a Field.Item with its own Field.Label and Field.Description. The description is wired to that radio with aria-describedby, so it is read out with the option rather than left floating in the group.

radio/descriptions.tsx
Visibility

Only you and the people you invite can see this project.

Everyone in your organisation can see it. Nobody outside can.

Anyone on the internet can see it. Only you can push.

Controlled

Pass value and onValueChange for a controlled group; defaultValue for an uncontrolled one. The callback receives the new value first and Base UI's event details second.

radio/controlled.tsx
Email me

One email, every morning.

Disabled and read-only

disabled blocks pointer and keyboard interaction and sets aria-disabled. On a single option, put it on the Field.Item rather than on the Radio: that puts data-disabled on the label too, so the words dim with the circle instead of staying at full contrast beside a greyed-out control. RadioGroup takes disabled as well, which reaches every option inside it.

radio/disabled.tsx
Plan
Billing currency

Set when the workspace was created. Contact support to change it.

Log retention

Labelling a group

A radio group needs two kinds of name, and they are separate problems. The group is named by pointing aria-labelledby at a heading — it cannot be a <label>, because a <label> resolves to a single control and role="radiogroup" is not a labelable element. Each option is then named by an enclosing <label>, which works because the root renders a <span> with the hidden <input> beside it.

radio/labelling.tsx
Merge strategy

The Field pattern used elsewhere on this page does both of these for you: Field.Label nativeLabel={false} names the group without htmlFor, and each Field.Item's label names its own row.

Rendering as a native button

The root is a <span> by default so that an enclosing <label> stays valid HTML. For the sibling-label pattern — a separate <label htmlFor> pointing at an id — you need a real <button>, which means nativeButton together with render.

radio/native-button.tsx
Theme

In a form

Field.Root's name is what the value is submitted under, so the group itself needs no name when it sits in a field. required on the group blocks submission until something is picked and matches Field.Error match="valueMissing", exactly as it would for a required <input>.

radio/form.tsx
Subscription
Seats
Billing period

Accessibility

Keyboard interactions
KeyBehaviour
TabMoves focus into the group, landing on the selected option — or on the first one if nothing is selected. The whole group is a single tab stop.
ArrowUp then ArrowLeftMoves to the previous option and selects it, wrapping from the first to the last. Both keys work whichever way the group is laid out.
ArrowDown then ArrowRightMoves to the next option and selects it, wrapping from the last to the first.
SpaceSelects the focused option. Only reachable when the group started with nothing selected — after that, arrowing has already selected whatever is focused.
EnterDoes not select. Inside a form it submits the form, matching a native radio.

Disabled options are skipped by the arrow keys. Home and End are deliberately not bound — Base UI turns them off for radio groups so they keep scrolling the page.

The group renders a <div role="radiogroup">; each option exposes role="radio" and aria-checked. The <input> beside each one is aria-hidden and exists to carry the value into form submission, so name, value, required and form behave as they would on a native radio.

Motion. The dot scales from 0 to 1 on a duration token, not on a --forte-scale-* token, and that distinction is the whole reason it survives reduced motion. A scale token collapses toward 1 under prefers-reduced-motion: reduce, which for a mark animating toward 1 would mean never appearing at all — and the dot is the only thing separating the selected option from the rest for anyone who cannot rely on the fill colour. Durations only shorten, so the dot still pops; --forte-duration-spring-snappy drops from 400ms to 120ms and the spring flattens into what reads as an instant appearance.

The way out is deliberately not the spring. A spring undershoots, so scaling 1 → 0 on one would drive the dot through negative values and visibly rebound after it had gone; the un-select runs on standard easing instead.

Under forced colours the circle paints with Canvas/CanvasText, a selected one with Highlight and a HighlightText dot, and a disabled one with GrayText on Canvas — the dot stays visible in every state.

Theming

Every visual decision is a custom property declared on the radio root. Set them on the radio itself — through className or an inline style — rather than on an ancestor: the component declares its own defaults on the root element, and an element's own declaration beats an inherited value.

Theming tokens for Radio
PropertyControlsDefault
--forte-radio-sizeWidth and height of the painted circle. size="sm" sets 0.875rem, size="lg" sets 1.25rem.1rem
--forte-radio-radiusCorner radius of the circle.50%
--forte-radio-border-widthBorder width.1px
--forte-radio-dot-ratioSize of the dot as a fraction of the circle. Unitless on purpose, so the dot keeps its proportion at any --forte-radio-size. size retunes it: 0.4 for sm, 0.36 for lg.0.375
--forte-radio-dot-sizeThe dot's own width and height. Derived from the two above; set it directly to break the proportion.calc( var(--forte-radio-size) * var(--forte-radio-dot-ratio) )
--forte-radio-durationColour transitions on the circle, the dot's fade, and the un-select.var(--forte-duration-fast)
--forte-radio-pop-durationHow long the dot takes to pop in.var(--forte-duration-spring-snappy)
--forte-radio-pop-easeThe easing that pop rides. Change it and change the duration with it — a spring truncated by a shorter duration looks broken.var(--forte-ease-spring-snappy)
--forte-radio-bgFill while unselected.var(--forte-color-background)
--forte-radio-bg-hoverFill on hover while unselected.var(--forte-color-panel-hover)
--forte-radio-border-colorBorder colour while unselected.var(--forte-color-border)
--forte-radio-border-color-hoverBorder colour on hover while unselected.var(--forte-color-border-strong)
--forte-radio-fillFill while selected.var(--forte-color-primary)
--forte-radio-fill-hoverFill on hover while selected.var(--forte-color-primary-hover)
--forte-radio-dot-colorColour of the dot — it becomes the root's color, and the dot paints with currentColor.var(--forte-color-on-primary)

RadioGroup declares one property of its own:

Theming tokens for Radio
PropertyControlsDefault
--forte-radio-group-gapGap between options in the group. var(--forte-space-4) when orientation="horizontal".var(--forte-space-2)

The three -hover values only ever apply on devices that report hover support, so a touch device never sticks in a hover colour after a tap.

tone reassigns --forte-radio-fill, --forte-radio-fill-hover and --forte-radio-dot-colorprimary, secondary, danger or neutral. Inside a Field.Root, an invalid field overrides those and the border colours with the danger palette, so a validation failure never renders in a brand colour.

API reference

Radio

Props for Radio
PropTypeDefaultDescription
value*ValueThe value this radio submits when it is the one selected. Matched against the group's `value`, so it has to be unique within the group. Required — a radio with no value cannot be selected.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
indicatorClassNamestringAdditional class name(s) for the indicator — the dot inside the circle. Applied after the internal styles.
nativeButtonbooleanfalseSet to `true` when `render` replaces the root with a real `<button>`. The root is a `<span>` by default so that an enclosing `<label>` stays valid HTML; for the sibling-label pattern (`<label htmlFor>` + `id`) pass `nativeButton render={<button />}` instead. With `nativeButton`, `id` lands on the root element rather than on the hidden input.
refRef<HTMLElement>Ref to the root element. Declared as a prop rather than through `forwardRef` because the component is generic — a `forwardRef` wrapper would erase `Value` and with it the inference on `value`.
sizeRadioSizemdSize of the painted circle. The dot is derived from it rather than fixed, so it keeps its proportion at every size and when a consumer overrides `--forte-radio-size` to something off the scale.
toneRadioToneprimaryWhich semantic colour set the selected fill draws from. Inside a `Field.Root` an invalid field overrides this with the danger palette, so a validation error always reads as an error.

Anything Base UI's Radio.Root accepts passes through as well — including disabled, readOnly, required, inputRef and render.

RadioGroup

Props for RadioGroup
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
defaultValueValueundefinedThe value selected on mount when uncontrolled. Leave it out for a group that starts with nothing selected — but prefer a default where one is defensible, because an empty group has no focusable option to arrow into until something is picked.
disabledbooleanfalseDisables every radio in the group.
namestringIdentifies the group in form submission. Inside a `Field.Root` the field's `name` is used instead, so this is only needed outside one.
orientationRadioGroupOrientationverticalDirection the options are laid out in. Horizontal groups wrap. Layout only — it does not change the keyboard model. Base UI drives the group with all four arrow keys whichever way it is laid out, so a horizontal group still answers to Up and Down.
readOnlybooleanfalseShows the current value but refuses to change it. Unlike `disabled` the options stay focusable and announce `aria-readonly`.
refRef<HTMLDivElement>Ref to the group element. Declared as a prop rather than through `forwardRef` because the component is generic — a `forwardRef` wrapper would erase `Value` and with it the typing of `onValueChange`.
requiredbooleanfalseRequires a selection before the surrounding form will submit.
valueValueundefinedThe selected value. Pairs with `onValueChange` for a controlled group; use `defaultValue` for an uncontrolled one.

onValueChange, form, inputRef, render and the rest of Base UI's RadioGroup props pass through unchanged.