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 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.
Orientation
orientation="horizontal" lays the options out in a row instead of a column;
horizontal groups wrap.
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.
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.
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.
Set when the workspace was created. Contact support to change it.
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.
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.
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>.
Accessibility
| Key | Behaviour |
|---|---|
| Tab | Moves 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 ArrowLeft | Moves 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 ArrowRight | Moves to the next option and selects it, wrapping from the last to the first. |
| Space | Selects the focused option. Only reachable when the group started with nothing selected — after that, arrowing has already selected whatever is focused. |
| Enter | Does 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.
| Property | Controls | Default |
|---|---|---|
--forte-radio-size | Width and height of the painted circle. size="sm" sets 0.875rem, size="lg" sets 1.25rem. | 1rem |
--forte-radio-radius | Corner radius of the circle. | 50% |
--forte-radio-border-width | Border width. | 1px |
--forte-radio-dot-ratio | Size 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-size | The 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-duration | Colour transitions on the circle, the dot's fade, and the un-select. | var(--forte-duration-fast) |
--forte-radio-pop-duration | How long the dot takes to pop in. | var(--forte-duration-spring-snappy) |
--forte-radio-pop-ease | The 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-bg | Fill while unselected. | var(--forte-color-background) |
--forte-radio-bg-hover | Fill on hover while unselected. | var(--forte-color-panel-hover) |
--forte-radio-border-color | Border colour while unselected. | var(--forte-color-border) |
--forte-radio-border-color-hover | Border colour on hover while unselected. | var(--forte-color-border-strong) |
--forte-radio-fill | Fill while selected. | var(--forte-color-primary) |
--forte-radio-fill-hover | Fill on hover while selected. | var(--forte-color-primary-hover) |
--forte-radio-dot-color | Colour 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:
| Property | Controls | Default |
|---|---|---|
--forte-radio-group-gap | Gap 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-color — primary, 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
| Prop | Type | Default | Description |
|---|---|---|---|
value* | Value | The 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. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
indicatorClassName | string | Additional class name(s) for the indicator — the dot inside the circle. Applied after the internal styles. | |
nativeButton | boolean | false | Set 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. |
ref | Ref<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`. | |
size | RadioSize | md | Size 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. |
tone | RadioTone | primary | Which 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
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
defaultValue | Value | undefined | The 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. |
disabled | boolean | false | Disables every radio in the group. |
name | string | Identifies the group in form submission. Inside a `Field.Root` the field's `name` is used instead, so this is only needed outside one. | |
orientation | RadioGroupOrientation | vertical | Direction 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. |
readOnly | boolean | false | Shows the current value but refuses to change it. Unlike `disabled` the options stay focusable and announce `aria-readonly`. |
ref | Ref<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`. | |
required | boolean | false | Requires a selection before the surrounding form will submit. |
value | Value | undefined | The 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.