Skip to content

Radio

Radio is a single native input with the same color and label contracts as Checkbox. What is different is what is missing: no shape, no mixed state, and no RadioGroup, because the platform already groups radios better than a component could.

  • Stable
  • React 19
  • One DOM node
  • Platform grouping

Radios come in groups, and the group is the platform’s:

Delivery

3–5 business days

Next business day

Ready in 2 hours

selected: standard

Tab reaches the group once, then the arrow keys move between the options — none of that is JavaScript.

Code
import { useState } from "react";
import { Radio } from "wayra-ui";
const OPTIONS = [
{ value: "standard", label: "Standard", helper: "3–5 business days" },
{ value: "express", label: "Express", helper: "Next business day" },
{ value: "pickup", label: "Pick up in store", helper: "Ready in 2 hours" },
];
export function GroupDemo() {
const [delivery, setDelivery] = useState("standard");
return (
<div className="hu-demo-col">
{/* The grouping is the platform's: a shared `name` gives mutual
exclusion, arrow-key navigation and a single tab stop for free, and
fieldset/legend is the accessible grouping. There is no RadioGroup. */}
<fieldset className="hu-fieldset">
<legend>Delivery</legend>
{OPTIONS.map((option) => (
<Radio
key={option.value}
name="delivery"
value={option.value}
label={option.label}
helper={option.helper}
checked={delivery === option.value}
onChange={(event) => setDelivery(event.target.value)}
/>
))}
</fieldset>
<p className="hu-readout">selected: {delivery}</p>
<p className="hu-hint">
Tab reaches the group once, then the arrow keys move between the options
— none of that is JavaScript.
</p>
</div>
);
}

A shared name gives you mutual exclusion, roving arrow-key focus, and a single tab stop. <fieldset><legend> is the accessible grouping. None of that is JavaScript, and none of it is a component.

Change the props and copy the code.

Shipping
color
size
label
state

selected: standard

Generated code
TSX
<fieldset>
  <legend>Shipping</legend>
  <Radio
    name="shipping"
    value="standard"
    label="Standard"
    defaultChecked
  />
  <Radio
    name="shipping"
    value="express"
    label="Express"
  />
  <Radio
    name="shipping"
    value="overnight"
    label="Overnight"
  />
</fieldset>
Code
<Radio color="primary" aria-label="primary" defaultChecked />
<Radio color="secondary" aria-label="secondary" defaultChecked />
<Radio color="accent" aria-label="accent" defaultChecked />
<Radio color="neutral" aria-label="neutral" defaultChecked />
<Radio color="info" aria-label="info" defaultChecked />
<Radio color="success" aria-label="success" defaultChecked />
<Radio color="warning" aria-label="warning" defaultChecked />
<Radio color="error" aria-label="error" defaultChecked />
<input type="radio" class="wayra-radio" name="color-primary" data-color="primary" checked aria-label="primary" />
<input type="radio" class="wayra-radio" name="color-secondary" data-color="secondary" checked aria-label="secondary" />
<input type="radio" class="wayra-radio" name="color-accent" data-color="accent" checked aria-label="accent" />
<input type="radio" class="wayra-radio" name="color-neutral" data-color="neutral" checked aria-label="neutral" />
<input type="radio" class="wayra-radio" name="color-info" data-color="info" checked aria-label="info" />
<input type="radio" class="wayra-radio" name="color-success" data-color="success" checked aria-label="success" />
<input type="radio" class="wayra-radio" name="color-warning" data-color="warning" checked aria-label="warning" />
<input type="radio" class="wayra-radio" name="color-error" data-color="error" checked aria-label="error" />

Selected, the ring fills with the role and -content draws the dot. Those are the same two tokens every other component reads, with no third color invented for the mark.

There is no customColor prop. Per-instance color math is the opposite of the token contract, and the capability already exists with no API at all:

Code
<Radio
style={{
'--wayra-color-primary': 'oklch(58% 0.2 300)',
'--wayra-color-primary-content': 'oklch(99% 0 0)',
}}
aria-label="Custom purple"
defaultChecked
/>
<input type="radio" class="wayra-radio" name="custom" data-size="lg" checked aria-label="Custom purple" style="--wayra-color-primary: oklch(58% 0.2 300); --wayra-color-primary-content: oklch(99% 0 0)" />

The same --wayra-size-selector ladder Checkbox walks, on the same multipliers. A radio and a checkbox at the same step are always the same size side by side.

Code
<Radio size="xs" aria-label="xs" defaultChecked />
<Radio size="sm" aria-label="sm" defaultChecked />
<Radio size="md" aria-label="md" defaultChecked />
<Radio size="lg" aria-label="lg" defaultChecked />
<Radio size="xl" aria-label="xl" defaultChecked />
<input type="radio" class="wayra-radio" name="size-xs" data-size="xs" checked aria-label="xs" />
<input type="radio" class="wayra-radio" name="size-sm" data-size="sm" checked aria-label="sm" />
<input type="radio" class="wayra-radio" name="size-md" data-size="md" checked aria-label="md" />
<input type="radio" class="wayra-radio" name="size-lg" data-size="lg" checked aria-label="lg" />
<input type="radio" class="wayra-radio" name="size-xl" data-size="xl" checked aria-label="xl" />

The dot is not one fixed fraction of the ring. The border takes a fixed 1px — 2px on brutal — out of every step, so a single ratio would squeeze the xs dot against its own ring while the xl one grew into a target. The dot is 0.27 of the ring plus a fixed 0.9 × --wayra-size-selector, so its share goes down as the box grows:

Size Ring Dot Share
xs 16px 7.9px 0.49
sm 18px 8.5px 0.47
md 20px 9px 0.45
lg 24px 10.1px 0.42
xl 28px 11.2px 0.40

The dot always grows, and never becomes a blob. Checkbox needs no such table, because its tick uses a flat 0.62.

Nothing to configure. name, value, ref, checked, defaultChecked and onChange all come from ComponentProps<"input">, so a native form, TanStack Form or Formik work with no adapter. FormData posts one entry under the shared name, carrying the selected option’s value, and omits the key entirely when nothing is selected.

react-hook-form is built in, the same way it is on Input — with one deliberate difference, below. Inside Form, which provides the useForm instance you own, every option registers itself on name alone. That is the same one prop that already makes them a group in the browser: each Radio calls register("plan") for itself, react-hook-form de-duplicates by name and collects the options’ refs, and the submitted value is the chosen option’s value. Nothing is shared between the options in your markup, so there is still nothing group-shaped to build. An explicit disabled wins over the freeze a submitting form applies, and noRhf turns all of it off for one option.

const schema = z.object({
plan: z.string().min(1, 'Choose a plan to continue.'),
});
const form = useForm({ defaultValues: { plan: '' }, resolver: zodResolver(schema) });
<Form form={form} onSubmit={save}>
<fieldset aria-describedby="plan-error">
<legend>Plan</legend>
<Radio name="plan" value="starter" label="Starter" />
<Radio name="plan" value="team" label="Team" />
</fieldset>
<PlanError />
<Button type="submit">Save</Button>
</Form>

This is the one place Radio parts from Input on purpose. A radio error is a group error — “pick one” fails for the set, not for the option that happens to be first — so the form’s message never lands in an option’s hint slot. It sets aria-invalid on every option, which turns all the rings red with no extra rule, and stops there. Three options sharing one message would otherwise announce it three times.

Placing that message is yours to do, exactly as the grouping is already was: one hint after the <fieldset>, pointed at by aria-describedby. What react-hook-form adds is the text, read with useRHFIntegration — the hook every field component calls internally, exported for this:

function PlanError() {
const plan = useRHFIntegration('plan');
// Always rendered, empty until there is something to say: the fieldset's
// aria-describedby never points at a missing id, and a live region that
// exists before it fills is the one that gets announced.
return (
<p id="plan-error" role="alert">
{plan.error?.message}
</p>
);
}

PlanError has to be a child of Form. A component that renders Form sits above the provider it creates, so a hook called up there reads nothing, silently.

The per-option error prop is untouched by all of this. Pass one and it takes that option’s hint slot with role="alert", the way it always did — reach for it only when the message really is about one option.

Building one would mean reimplementing the roving focus, the arrow-key wrap, the single tab stop, and the form value that the browser already handles. A RadioGroup would exist only to inject name, which is one prop you can spread yourself.

<fieldset>
<legend>Delivery</legend>
<Radio name="delivery" value="standard" label="Standard" defaultChecked />
<Radio name="delivery" value="express" label="Express" />
</fieldset>

The radius is 50%, unconditionally. --wayra-radius-selector is deliberately not read, so a theme that squares its checkboxes still gets a round radio.

Round versus square is how a user tells “pick one” from “pick many” at a glance. Themes cannot override this.

The indeterminate DOM property exists on radios, but it carries no visual and no assistive meaning. A group with nothing selected is just a group with nothing selected, so there is no prop for it.

It does leave one mark on the stylesheet. :indeterminate matches every radio in an untouched group, which is why Checkbox keeps its :indeterminate rule scoped to .wayra-checkbox instead of sharing it.

Two months free.

Code
<Radio name="plan" value="annual" label="Annual" helper="Two months free." defaultChecked />
<Radio name="plan" value="monthly" label="Monthly" labelPlacement="left" />
<div class="wayra-label" data-placement="right" data-size="md">
<input type="radio" class="wayra-radio" name="plan" id="radio-helper" checked aria-describedby="radio-helper-hint" />
<label class="wayra-label-text" for="radio-helper">Annual</label>
<p class="wayra-label-hint" id="radio-helper-hint">Two months free.</p>
</div>
<div class="wayra-label" data-placement="left" data-size="md">
<input type="radio" class="wayra-radio" name="plan" id="radio-left" />
<label class="wayra-label-text" for="radio-left">Monthly</label>
</div>

Identical to Checkbox: labelPlacement is right (default) or left, there is one hint slot, the error carries role="alert", and the hint color keys on the control’s own state. Input is the canonical page for all of it.

top and floating are absent for the same reason as on Checkbox. floating keys on :placeholder-shown, which a radio never matches, and top over a small circle reads as a heading over nothing.

RadioProps extends ComponentProps<"input"> minus type, size, and color.

Prop Type Default Description
color WayraColor "primary" The edge unselected, the fill selected.
size "xs" | "sm" | "md" | "lg" | "xl" "md" Off --wayra-size-selector, same steps as Checkbox.
label ReactNode — Wraps the control in the label contract.
labelPlacement "right" | "left" "right" No top, no floating.
helper ReactNode — Per-control hint. A group hint belongs on the fieldset.
error ReactNode — Takes the hint slot, adds role="alert", sets aria-invalid.
unstyled boolean false Drops the classes, keeps the data attributes.

name, value, checked, defaultChecked, onChange, required, and ref are the native props, typed. There is no shape, no indeterminate, and no width.

Key Behavior
Tab Reaches the group once — the checked radio, or the first if none is.
↑ ↓ ← → Moves within the group and selects as it goes. Native, and it wraps.
Space Selects the focused radio.
  • Grouping is name plus <fieldset><legend>. Without a shared name there is no group: no mutual exclusion, no arrow keys, and every radio becomes its own tab stop.
  • Naming comes from label, or from aria-label when the radio renders bare. The legend names the group; the label names the option.
  • Focus uses :focus-visible, drawn once in states.css. The ring follows the control’s accent, so an invalid radio rings red with no extra rule.
  • Windows high contrast repaints the ring from system keywords and the dot as CanvasText, or GrayText when disabled.
  • Reduced motion drops the dot’s scale-in and the circle’s give under a press. The dot still appears, and a press still tints the circle.
  • Contrast: the selected ring pairs the role with its own -content dot, so the mark clears 4.5:1 in every theme that ships.

Do

Select one option up front. A group with a sensible default is a group the user can leave alone.

Don't

Ship the group with nothing selected. There is no way back to this state once the user touches it.

Do

Round for one of these, square for any of these. The shapes are the instruction.

Don't

Round checkboxes in a list. They look like a single choice and behave like several.

Do

Delivery

One shared name inside a

with a legend. That is the whole group: mutual exclusion, arrow keys, and one tab stop.

Don't

Delivery

A different name per radio. Both can be selected at once, and each one becomes its own tab stop.