Skip to content

Radio group

  • Two to about five options people should see all at once
  • Choices that are easier to compare side by side: delivery speed, plan
  • A required choice in a form, with nothing chosen at first

Not for a long list — use a Select — or an on/off choice — use a Checkbox or a Switch.

Choosing between choosing one option or a value:

ComponentUse it forFor example
RadioGroupUp to about five options, all visible at once, as a form valueShipping speed, plan size
SelectOne option from a longer list, in little space, with the system pickerCountry, time zone, sort order in a form
ComboboxA long list people search by typing, several picks, or options loaded from a serverAssign reviewers, search a city, add tags
ToggleGroupA few options shown as buttons that apply at once, not a form fieldText alignment, list or grid view
SliderA number in a range, where dragging is easier than typing an exact valueVolume, brightness, price range
Plan
import { RadioGroup } from '@arun-dev/ui';
const row = { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' };
export default function RadioGroupBasics() {
return (
<>
{/* The group is named by its caption; each radio by the label wrapping it, which
names a real <input> natively. Under jsx-a11y, pair each label by id as well. */}
<span id="plan-caption" className="text-size-sm text-color-secondary">
Plan
</span>
<RadioGroup.Root aria-labelledby="plan-caption" name="plan" defaultValue="pro">
<label style={row}>
<RadioGroup.Item value="free" />
Free
</label>
<label style={row}>
<RadioGroup.Item value="pro" />
Pro
</label>
<label style={row}>
<RadioGroup.Item value="enterprise" disabled />
Enterprise
</label>
</RadioGroup.Root>
</>
);
}

It needs names — two of them

The group needs one, and so does each radio. Point the group at its caption with aria-labelledby, or render it as a <fieldset> with a <legend>. Wrap each radio in a <label>:

<RadioGroup.Root aria-labelledby="plan-caption">
<label>
<RadioGroup.Item value="free" />
Free
</label>
</RadioGroup.Root>

A radio is a real <input>, so the wrapping label names it natively. jsx-a11y/label-has-associated-control cannot see through RadioGroup.Item to the input it renders, though, and rejects the wrapping on its own. Under that rule, either pair each label by id as well (<label htmlFor="free"> with <RadioGroup.Item id="free">), or list RadioGroup.Item in the rule’s controlComponents once.

Each RadioGroup.Item renders a real <input type="radio">. Checkbox and Switch render a <button> with a hidden input, because an input cannot hold an indicator. A radio needs no indicator: its dot is a ::before drawn in CSS. So the native element passes all three tests in decision 3, and everything it does comes from the platform:

  • Arrow keys move the selection, and wrap at the ends.
  • Tab enters the group once, at the checked radio (or the first, when none is checked), and leaves it on the next Tab.
  • required blocks submission until something is chosen, using the browser’s own message.
  • FormData, form.elements and :checked all see ordinary radios.

What the group adds is a single value in React. Every radio derives checked from it, so the screen, the data-* attributes and the submitted value cannot disagree.

<RadioGroup.Root><RadioGroup.Item value="free" /> …</RadioGroup.Root>

Pass value with onValueChange and the parent owns the selection. The group will not move on its own. null means nothing is selected.

none
import { useState } from 'react';
import { RadioGroup } from '@arun-dev/ui';
const row = { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' };
export default function RadioGroupControlled() {
// `null` is "nothing selected" — the state a native group starts in.
const [size, setSize] = useState<string | null>(null);
return (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)' }}>
<RadioGroup.Root
aria-label="Size"
value={size}
onValueChange={setSize}
// Laid out in a row by overriding the default column.
style={{ flexDirection: 'row', gap: 'var(--space-sm)' }}
>
{['S', 'M', 'L'].map((option) => (
<label key={option} style={row}>
<RadioGroup.Item value={option} />
{option}
</label>
))}
</RadioGroup.Root>
{/* The parent owns the value, so the group only moves when state changes. */}
<span className="text-size-sm text-color-secondary">{size ?? 'none'}</span>
</div>
);
}

Never pass an undefined value

The mode is decided once, at mount. A value of undefined on the first render makes the group uncontrolled for the rest of its life. Use null for “nothing selected” and coalesce at the call site:

<RadioGroup.Root value={plan ?? null} onValueChange={setPlan} />

Give the group a name and it submits the selected value under it. Nothing is submitted while no radio is selected, or while the group is disabled.

form.reset() returns the group to the value it mounted with, including “nothing selected”, and reports the change through onValueChange. The platform resets the inputs by itself, but React’s value would not follow without this.

Delivery
FormData will appear here
import { useState } from 'react';
import type { SubmitEvent } from 'react';
import { Button, RadioGroup } from '@arun-dev/ui';
const row = { display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2xs)' };
export default function RadioGroupFieldset() {
const [submitted, setSubmitted] = useState('');
function onSubmit(event: SubmitEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitted(JSON.stringify(Object.fromEntries(new FormData(event.currentTarget))));
}
return (
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--space-sm)' }}>
{/* A fieldset through `render`, so the legend names the group natively. `required`
is the platform's: submit is blocked until something is chosen. */}
<RadioGroup.Root render={<fieldset />} name="delivery" required className="fieldset">
<legend className="fieldset-legend">Delivery</legend>
<label style={row}>
<RadioGroup.Item value="standard" />
Standard
</label>
<label style={row}>
<RadioGroup.Item value="express" />
Express
</label>
</RadioGroup.Root>
<div style={{ display: 'flex', gap: 'var(--space-2xs)' }}>
<Button type="submit" variant="primary">
Submit
</Button>
{/* form.reset() returns the group to the value it mounted with — here, none. */}
<Button type="reset" onClick={() => setSubmitted('')}>
Reset
</Button>
</div>
<code className="text-size-sm">{submitted || 'FormData will appear here'}</code>
</form>
);
}

name is optional, but the group always has one. The platform only moves the selection between radios that share a name, so one is generated when you omit it. A generated name still submits, so pass your own when the group sits inside a form you read.

Bind it with Controller. register() reads a native radio correctly, but react-hook-form’s setValue and reset write .checked on the DOM directly, and the group’s value never hears of it. See the react-hook-form guide.

<Controller
control={control}
name="plan"
render={({ field }) => (
<RadioGroup.Root
name={field.name}
value={field.value}
onValueChange={field.onChange}
onBlur={field.onBlur}
aria-label="Plan"
>
{/* items */}
</RadioGroup.Root>
)}
/>
import { RadioGroup } from '@arun-dev/ui';
<RadioGroup.Root>
<RadioGroup.Item value="a" />
</RadioGroup.Root>;

Two parts, and no indicator. RadioGroup.Item is a void <input>, so there is nothing for an indicator part to wrap. Decision 6 gives one element one component.

RadioGroup.Root props
PropTypeDefaultDescription
valuestring | nullControlled value — the selected item's value, or null for none. Provide onValueChange alongside it. Never pass undefined — the mode is fixed at mount, so write value={x ?? null}.
defaultValuestring | nullnullInitial value when uncontrolled. Read once, at mount.
onValueChange(value: string | null) => voidCalled with the value being moved to, in both modes. null arrives only from form.reset() returning a group that mounted with nothing selected.
namestringgeneratedShared by every radio — the platform groups radios by name — and submitted with the form. Pass your own inside a form; a generated one still submits.
disabledbooleanfalseDisables every radio.
requiredbooleanfalseBlocks form submission until a radio is selected, via native validation.
formstringId of the <form> the radios belong to, when the group is rendered outside it.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
RadioGroup.Item props
PropTypeDefaultDescription
valuestringRequired. The group's value when this radio is selected, and what the form submits for it.
disabledbooleanfalseDisables this radio alone. A disabled group disables it regardless.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.

name, checked, defaultChecked, required and form are not accepted on an item. They belong to the group, and a radio’s checkedness is the group’s value rather than its own.

Derive wrapper types from the component, as for Checkbox: ComponentProps<typeof RadioGroup.Root>. With the headless parts directly, the types come from the package that owns them: import type { RadioGroupRootProps, RadioGroupItemProps } from '@arun-dev/headless/radio-group'.

  • RadioGroup.Root renders <div role="radiogroup">, with aria-disabled and aria-required when set. Render a <fieldset> through render to name it with a <legend>.
  • Each RadioGroup.Item is a native <input type="radio">. Focus, arrow keys, Tab order, disabled and required come from the platform.
  • readOnly is not offered. The platform ignores readonly on radios, so it would be pure JavaScript layered over an element that does not honour it.
  • Items emit data-checked / data-unchecked / data-disabled, and the group emits data-disabled: the same shape as every other control here. :checked works as well. The data-* attributes are the ones that follow the component’s state when a render element takes over.
  • The dot’s transition is disabled under prefers-reduced-motion.

@arun-dev/headless ships the same two parts with no CSS and no class names. Its radio is an unstyled native input, so it looks like the browser’s until you give it appearance: none.

import { RadioGroup } from '@arun-dev/headless/radio-group';