Radio group
When to use
Section titled “When to use”- 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:
| Component | Use it for | For example |
|---|---|---|
| RadioGroup | Up to about five options, all visible at once, as a form value | Shipping speed, plan size |
| Select | One option from a longer list, in little space, with the system picker | Country, time zone, sort order in a form |
| Combobox | A long list people search by typing, several picks, or options loaded from a server | Assign reviewers, search a city, add tags |
| ToggleGroup | A few options shown as buttons that apply at once, not a form field | Text alignment, list or grid view |
| Slider | A number in a range, where dragging is easier than typing an exact value | Volume, brightness, price range |
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.
Native, on purpose
Section titled “Native, on purpose”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.
requiredblocks submission until something is chosen, using the browser’s own message.FormData,form.elementsand:checkedall 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.
Playground
Section titled “Playground”<RadioGroup.Root><RadioGroup.Item value="free" /> …</RadioGroup.Root>Controlled
Section titled “Controlled”Pass value with onValueChange and the parent owns the selection. The group will not move on
its own. null means nothing is selected.
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} />In a form
Section titled “In a form”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.
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.
With react-hook-form
Section titled “With react-hook-form”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> )}/>Anatomy
Section titled “Anatomy”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
Section titled “RadioGroup.Root”| Prop | Type | Default | Description |
|---|---|---|---|
value | string | null | Controlled 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}. | |
defaultValue | string | null | null | Initial value when uncontrolled. Read once, at mount. |
onValueChange | (value: string | null) => void | Called with the value being moved to, in both modes. null arrives only from form.reset() returning a group that mounted with nothing selected. | |
name | string | generated | Shared 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. |
disabled | boolean | false | Disables every radio. |
required | boolean | false | Blocks form submission until a radio is selected, via native validation. |
form | string | Id of the <form> the radios belong to, when the group is rendered outside it. | |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
RadioGroup.Item
Section titled “RadioGroup.Item”| Prop | Type | Default | Description |
|---|---|---|---|
value | string | Required. The group's value when this radio is selected, and what the form submits for it. | |
disabled | boolean | false | Disables this radio alone. A disabled group disables it regardless. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything 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'.
Behaviour
Section titled “Behaviour”RadioGroup.Rootrenders<div role="radiogroup">, witharia-disabledandaria-requiredwhen set. Render a<fieldset>throughrenderto name it with a<legend>.- Each
RadioGroup.Itemis a native<input type="radio">. Focus, arrow keys, Tab order,disabledandrequiredcome from the platform. readOnlyis not offered. The platform ignoresreadonlyon 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 emitsdata-disabled: the same shape as every other control here.:checkedworks as well. Thedata-*attributes are the ones that follow the component’s state when arenderelement takes over. - The dot’s transition is disabled under
prefers-reduced-motion.
Styling it yourself
Section titled “Styling it yourself”@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';