MobTranslate styleguide

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

PartCurrent contract
GroupDiv radiogroup, vertical flex with 8px gaps; div ref
ItemDefault span radio and associated input; HTMLElement ref
Target / visual44px label target / 20px circle, 2px readable boundary
SelectedVisible 8px dot plus aria-checked; dot is not a separate action
Text--text-base label; long labels preserve their full meaning
FocusPrimitive 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 pointLocal choice
Entry and movementTab 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 sizesReference 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 statesA 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 styleReference 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.

Local reading density

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.

tsx
'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 / propContract
RadioGroup / RadioGroupPropsvalue/defaultValue unknown; onValueChange(value, details).
disabled / readOnly / requiredfalse defaults on group; unavailable/immutable/required are distinct.
name / inputRefGroup form name and hidden input ref.
RadioItem / RadioItemPropsInstalled Radio.Root props plus visible label; value identifies the item; disabled can exclude an item.
Item checkedDerived from the group value; not an independent controlled checkbox.
Names / native props / render / classNameInstalled native/root contracts; state callbacks retained.
RefsGroup 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.