Radio
Choose one alternative from a clearly named set without confusing focus with a different task result.
Choose one alternative
Use RadioGroup when choices are mutually exclusive and people benefit from seeing the alternatives together. Use CheckboxGroup for independent choices, Select for a compact large list, and ToggleGroup for optional pressed segments that may allow none.
The actual current value is one member value. Do not automatically interpret a changed selection as a successfully saved setting, a submitted form or a qualified review.
Anatomy and specifications
| Part | Current contract |
|---|---|
| Group | Div radiogroup, vertical flex with 8px gaps; div ref |
| Item | Default span radio and associated input; HTMLElement ref |
| Target / visual | 44px label target / 20px circle, 2px readable boundary |
| Selected | Visible 8px dot plus aria-checked; dot is not a separate action |
| Text | --text-base label; long labels preserve their full meaning |
| Focus | Primitive keyboard navigation and token ring; name the group as well as each item |
Reference defaults, placement and entry focus
Material recommends visible, vertically stacked alternatives, commonly five or fewer; a compact Select can save space at the cost of hiding options and extra interaction. Do not nest radio questions or use one radio for independent multi-selection. Clicking an option’s label and clicking its indicator must select the same value. Selection can affect a local preview immediately; a form/dialog may still need explicit submission.
The captured guidelines say to preselect one option, while the accessibility chapter permits a group with no initial selection. Choose an actual valid task default rather than preselecting an answer to a question that has not been answered. This example has a genuine Compact reading default. A required initially empty question needs linked error recovery; to allow opting out after selection, expose a meaningful None/Not applicable option or an explicit clear action, rather than treating a selected radio as a deselecting toggle.
| Reference point | Local choice |
|---|---|
| Entry and movement | Tab enters at the selected item; with no selection, the installed primitive’s entry policy must be checked. Ordinary arrows change focus and selection, skip unavailable members and wrap; Space on a selected item does not clear it. Shift+Tab returns through the group without creating a new value. |
| Anatomy and sizes | Reference 20dp icon, 40dp state layer, 48dp target. Local nominal 20 CSS px circle/8px dot at a 16px root and 44px label target use source-bound CSS specifications, not native units. |
| Label and states | A group question and an adjacent label for each option supply stable names. Foreground label text does not recolor with the selected dot. Hover, focus, pressed, disabled and read-only stay distinct from submitted/persisted state. |
| Density and style | Reference guidance recommends 48×48 CSS px and a reversible opt-in density control. Local targets have a 44px minimum and grow for text; the library does not implement Material’s state-layer/ripple or expose density reduction by default. |
Working local spacing and form
The default starts with Compact selected. Require an explicit radio choice clears that value and exposes the distinct required-empty entry path: Tab can focus an option without claiming a selected value, submit explains/focuses the missing choice, and Space/arrows make a real selection. The preview explicitly retains baseline spacing while no choice exists. Read-only does not satisfy a missing required choice; the independent Restore action recovers the default sample.
This English-only reading sample changes its local spacing without changing the text or any source record.
Selected local density: compact. No local radio form submitted.
'use client';
import { useRef, useState } from 'react';
import { Button, RadioGroup, RadioItem, Switch } from '@mobtranslate/ui';
export function RadioSelectionExample() {
const [value, setValue] = useState('compact');
const [readOnly, setReadOnly] = useState(false);
const [result, setResult] = useState('No local radio form submitted.');
const [required,setRequired]=useState(false);
const [invalid,setInvalid]=useState(false);
const first=useRef<HTMLElement>(null);
const effective=value||'compact';
return <div data-radio-lab className="space-y-4">
<Button type="button" variant="outline" aria-pressed={readOnly} onClick={() => setReadOnly(current => !current)}>Make local radio group read-only</Button>
<div className="flex flex-wrap gap-3"><Button type="button" variant="outline" aria-pressed={required} onClick={()=>{setRequired(current=>!current);setValue('');setInvalid(false);setResult('No new local radio form submitted.');}}>Require an explicit radio choice</Button><Button data-radio-restore type="button" variant="outline" onClick={()=>{setRequired(false);setValue('compact');setReadOnly(false);setInvalid(false);}}>Restore initial radio sample</Button></div>
<form noValidate className="space-y-3" onSubmit={event => { event.preventDefault(); if(required&&!value){setInvalid(true);first.current?.focus();return;}setInvalid(false);setResult(`Local FormData density=${new FormData(event.currentTarget).get('density')}.`); }}>
<fieldset><legend className="font-semibold">Local reading density</legend>
<RadioGroup name="density" value={value} readOnly={readOnly} required={required} aria-invalid={invalid||undefined} aria-describedby={invalid?'radio-required-error':undefined} onValueChange={next => { if (typeof next === 'string') {setValue(next);setInvalid(false);} }} aria-label="Local reading density">
<RadioItem ref={first} value="compact" label="Compact reading" /><RadioItem value="comfortable" label="Comfortable reading" /><RadioItem value="unavailable" label="Unavailable reading option" disabled />
</RadioGroup>
{invalid&&<p id="radio-required-error" role="alert">Choose a reading density before submitting.</p>}
</fieldset>
<Button type="submit">Submit radio sample</Button>
</form>
<p dir="auto" data-density-sample style={{lineHeight:effective === 'compact' ? 1.5 : 1.75, paddingBlock:effective === 'compact' ? 8 : 16}}>This English-only reading sample changes its local spacing without changing the text or any source record.</p>
<p role="status" dir="auto">Selected local density: {value||'(none)'}. {value?'':'The preview retains compact baseline spacing until a choice is made. '}{result}</p>
</div>;
}
export function SwitchSelectionExample() {
const [checked, setChecked] = useState(false);
const [details,setDetails]=useState(false);
const [pending, setPending] = useState(false);
const [requested, setRequested] = useState(false);
const [result, setResult] = useState('Local reminders are off.');
const control = useRef<HTMLElement>(null);
return <div data-switch-lab className="space-y-4">
<Switch ref={control} label="Local example reminders" checked={checked} readOnly={pending} aria-describedby="local-switch-state" onCheckedChange={next => { if (!pending) { setRequested(next); setPending(true); setResult('Local change pending; current setting is retained until an outcome.'); } }} />
<p id="local-switch-state" role="status">{result}</p>
{pending && <div className="flex flex-wrap gap-3"><Button type="button" onClick={() => { setChecked(requested); setPending(false); control.current?.focus(); setResult(`Local reminders are ${requested ? 'on' : 'off'}. No reminder was scheduled.`); }}>Apply local switch change</Button><Button type="button" variant="secondary" onClick={() => { setPending(false); control.current?.focus(); setResult('Simulated change failed. Previous local setting remains; retry is available.'); }}>Fail local switch change</Button></div>}
<Switch label="Disabled on sample" checked disabled />
<Switch label="Show local example details" checked={details} onCheckedChange={setDetails} aria-controls="local-switch-details" />
<div id="local-switch-details" hidden={!details}>These English-only local details appear immediately. No apply action or request is required for this display setting.</div>
<p dir="auto">The stable label names the feature. The checked state describes the retained setting, not the requested change.</p>
</div>;
}
API reference
| Export / prop | Contract |
|---|---|
| RadioGroup / RadioGroupProps | value/defaultValue unknown; onValueChange(value, details). |
| disabled / readOnly / required | false defaults on group; unavailable/immutable/required are distinct. |
| name / inputRef | Group form name and hidden input ref. |
| RadioItem / RadioItemProps | Installed Radio.Root props plus visible label; value identifies the item; disabled can exclude an item. |
| Item checked | Derived from the group value; not an independent controlled checkbox. |
| Names / native props / render / className | Installed native/root contracts; state callbacks retained. |
| Refs | Group HTMLDivElement; item HTMLElement (span default). |
Keyboard and state
- In the ordinary group, Tab enters/leaves; arrows move among available choices and change the selected value; Space selects the focused mutable item.
- The selected, disabled and read-only states must remain coherent with the actual group value and form payload. RadioGroup exposes aria-readonly; individual radio items retain their immutable behavior and data-readonly styling without emitting the unsupported aria-readonly attribute on the radio role.
- Toolbar-contained radio behavior is a separate composition contract; do not assume ordinary arrow selection inside another composite widget.
- Use a stable visible group question and option labels. An error belongs to the named group and must retain the choices.
Failure cases
- Several independent checked values in what is presented as one radio question.
- Arrow navigation landing on a disabled item or a focus-only change described as persisted success.
- An unnamed group with individually labelled members.
- A span ref documented as a button, or a hidden input form value ignored by the consumer.
Verification and release scope
- Check actual names, roles, state and focus with pointer and keyboard. Inspect the retained value and submitted FormData when relevant.
- Exercise empty, selected, mixed, disabled, read-only, invalid and pending/recovery states that apply to the control. Styling alone does not prove enforcement.
- Check both themes, long labels, 320px reflow, meaningful graphic/text contrast, reduced motion, forced colors and the actual target device.
- Use a named screen-reader/browser pair to verify spoken state. DOM assertions and local examples do not prove announcements or backend persistence.
- Selection never establishes language quality, source authority, consent, qualified review or permission. Keep that evidence explicit in the product task.