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:
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.
Playground
Section titled “Playground”Change the props and copy the code.
selected: standard
Generated code
<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>Colors
Section titled “Colors”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
<Radiostyle={{ '--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>The error marks, it does not speak
Section titled “The error marks, it does not speak”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.
What’s not included
Section titled “What’s not included”There is no RadioGroup
Section titled “There is no RadioGroup”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>No shape
Section titled “No shape”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.
No mixed state
Section titled “No mixed state”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.
Label, helper, and error
Section titled “Label, helper, and error”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.
API reference
Section titled “API reference”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.
Accessibility
Section titled “Accessibility”| 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
nameplus<fieldset><legend>. Without a sharednamethere is no group: no mutual exclusion, no arrow keys, and every radio becomes its own tab stop. - Naming comes from
label, or fromaria-labelwhen the radio renders bare. The legend names the group; the label names the option. - Focus uses
:focus-visible, drawn once instates.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, orGrayTextwhen 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
-contentdot, so the mark clears 4.5:1 in every theme that ships.
Do and don’t
Section titled “Do and don’t”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
One shared name inside a with a legend. That is the whole group: mutual exclusion, arrow keys, and one tab stop.
Don't
A different name per radio. Both can be selected at once, and each one becomes its own tab stop.