Inputs
Radio group
A set of native radios under one question, for picking exactly one of a few visible options.
- Built on
<fieldset><legend><input type="radio">appearance: none:checked:user-invalid:has()- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.1 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { Radio, RadioGroup } from "@/components/equip/radio";
export default function Demo() {
return (
<div style={{ width: 200 }}>
<RadioGroup legend="Billing cycle">
<Radio name="demo-cycle" value="monthly" label="Monthly" defaultChecked />
<Radio name="demo-cycle" value="yearly" label="Yearly, save 15%" />
<Radio name="demo-cycle" value="invoice" label="Invoice on request" />
</RadioGroup>
</div>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div style="width:200px">
<fieldset class="eq-radio-group">
<legend class="eq-radio-legend">Billing cycle</legend>
<div class="eq-radio-options" data-orientation="vertical">
<label class="eq-radio">
<input type="radio" class="eq-radio-input" name="demo-cycle" checked value="monthly">
<span class="eq-radio-text">
<span class="eq-radio-label">Monthly</span>
</span>
</label>
<label class="eq-radio">
<input type="radio" class="eq-radio-input" name="demo-cycle" value="yearly">
<span class="eq-radio-text">
<span class="eq-radio-label">Yearly, save 15%</span>
</span>
</label>
<label class="eq-radio">
<input type="radio" class="eq-radio-input" name="demo-cycle" value="invoice">
<span class="eq-radio-text">
<span class="eq-radio-label">Invoice on request</span>
</span>
</label>
</div>
</fieldset>
</div>Examples
With descriptions
Each radio has an id, so its description is announced after the label instead of inside it.
Code
import { Radio, RadioGroup } from "@/components/equip/radio";
export default function Example() {
return (
<div style={{ maxWidth: 340 }}>
<RadioGroup id="radio-visibility" legend="Project visibility" hint="You can change this later in settings.">
<Radio
id="radio-visibility-private"
name="visibility"
value="private"
label="Private"
description="Only invited members can see or join."
defaultChecked
/>
<Radio
id="radio-visibility-internal"
name="visibility"
value="internal"
label="Internal"
description="Anyone in the Larder organisation can view."
/>
<Radio
id="radio-visibility-public"
name="visibility"
value="public"
label="Public"
description="Anyone with the link can view. Only members can edit."
/>
</RadioGroup>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="max-width:340px">
<fieldset id="radio-visibility" class="eq-radio-group" aria-describedby="radio-visibility-hint">
<legend class="eq-radio-legend">Project visibility</legend>
<p class="eq-radio-hint" id="radio-visibility-hint">You can change this later in settings.</p>
<div class="eq-radio-options" data-orientation="vertical">
<label class="eq-radio">
<input type="radio" id="radio-visibility-private" class="eq-radio-input" aria-labelledby="radio-visibility-private-label" aria-describedby="radio-visibility-private-description" name="visibility" checked value="private">
<span class="eq-radio-text">
<span class="eq-radio-label" id="radio-visibility-private-label">Private</span>
<span class="eq-radio-description" id="radio-visibility-private-description">Only invited members can see or join.</span>
</span>
</label>
<label class="eq-radio">
<input type="radio" id="radio-visibility-internal" class="eq-radio-input" aria-labelledby="radio-visibility-internal-label" aria-describedby="radio-visibility-internal-description" name="visibility" value="internal">
<span class="eq-radio-text">
<span class="eq-radio-label" id="radio-visibility-internal-label">Internal</span>
<span class="eq-radio-description" id="radio-visibility-internal-description">Anyone in the Larder organisation can view.</span>
</span>
</label>
<label class="eq-radio">
<input type="radio" id="radio-visibility-public" class="eq-radio-input" aria-labelledby="radio-visibility-public-label" aria-describedby="radio-visibility-public-description" name="visibility" value="public">
<span class="eq-radio-text">
<span class="eq-radio-label" id="radio-visibility-public-label">Public</span>
<span class="eq-radio-description" id="radio-visibility-public-description">Anyone with the link can view. Only members can edit.</span>
</span>
</label>
</div>
</fieldset>
</div>Horizontal
Arrow keys still move through the options in DOM order, whichever way they are laid out.
Code
import { Radio, RadioGroup } from "@/components/equip/radio";
export default function Example() {
return (
<RadioGroup legend="Instance size" orientation="horizontal">
<Radio name="instance" value="small" label="2 vCPU" />
<Radio name="instance" value="medium" label="4 vCPU" defaultChecked />
<Radio name="instance" value="large" label="8 vCPU" />
</RadioGroup>
);
}HTMLworks in any framework, with the part's CSS
<fieldset class="eq-radio-group">
<legend class="eq-radio-legend">Instance size</legend>
<div class="eq-radio-options" data-orientation="horizontal">
<label class="eq-radio">
<input type="radio" class="eq-radio-input" name="instance" value="small">
<span class="eq-radio-text">
<span class="eq-radio-label">2 vCPU</span>
</span>
</label>
<label class="eq-radio">
<input type="radio" class="eq-radio-input" name="instance" checked value="medium">
<span class="eq-radio-text">
<span class="eq-radio-label">4 vCPU</span>
</span>
</label>
<label class="eq-radio">
<input type="radio" class="eq-radio-input" name="instance" value="large">
<span class="eq-radio-text">
<span class="eq-radio-label">8 vCPU</span>
</span>
</label>
</div>
</fieldset>Disabled
Disable one option with its own disabled attribute, or the whole group by disabling the fieldset.
Code
import { Radio, RadioGroup } from "@/components/equip/radio";
export default function Example() {
return (
<div style={{ display: "grid", gap: 16 }}>
<RadioGroup legend="Backup frequency">
<Radio name="backups" value="daily" label="Daily" defaultChecked />
<Radio name="backups" value="hourly" label="Hourly (Enterprise plan)" disabled />
</RadioGroup>
<RadioGroup legend="Data region" disabled>
<Radio name="region" value="eu-west-1" label="eu-west-1" defaultChecked />
<Radio name="region" value="us-east-1" label="us-east-1" />
</RadioGroup>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:16px">
<fieldset class="eq-radio-group">
<legend class="eq-radio-legend">Backup frequency</legend>
<div class="eq-radio-options" data-orientation="vertical">
<label class="eq-radio">
<input type="radio" class="eq-radio-input" name="backups" checked value="daily">
<span class="eq-radio-text">
<span class="eq-radio-label">Daily</span>
</span>
</label>
<label class="eq-radio">
<input disabled type="radio" class="eq-radio-input" name="backups" value="hourly">
<span class="eq-radio-text">
<span class="eq-radio-label">Hourly (Enterprise plan)</span>
</span>
</label>
</div>
</fieldset>
<fieldset disabled class="eq-radio-group">
<legend class="eq-radio-legend">Data region</legend>
<div class="eq-radio-options" data-orientation="vertical">
<label class="eq-radio">
<input type="radio" class="eq-radio-input" name="region" checked value="eu-west-1">
<span class="eq-radio-text">
<span class="eq-radio-label">eu-west-1</span>
</span>
</label>
<label class="eq-radio">
<input type="radio" class="eq-radio-input" name="region" value="us-east-1">
<span class="eq-radio-text">
<span class="eq-radio-label">us-east-1</span>
</span>
</label>
</div>
</fieldset>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
legend | ReactNode | none | RadioGroup. The question the options answer. |
hint | ReactNode | none | RadioGroup. Help text under the legend. Pass id as well so it is linked to the group. |
orientation | "vertical" | "horizontal" | "vertical" | RadioGroup. Lays the options out in a column or a wrapping row. |
...rest | ComponentProps<"fieldset"> | none | RadioGroup. Every native fieldset attribute, including disabled and name for form access. |
label | ReactNode | none | Radio. Visible label. Clicking it selects the option. |
description | ReactNode | none | Radio. Secondary line under the label. Pass id as well so it is read as a description. |
name | string | none | Radio. Required in practice: give every radio in a group the same name, or arrow keys will not move between them. |
className | string | none | Radio. Added to the root label, not the input. |
...rest | Omit<ComponentProps<"input">, "type"> | none | Radio. Every native input attribute and ref, applied to the input. type is always radio. |
Anatomy
| group | .eq-radio-group | The native fieldset. |
|---|---|---|
| legend | .eq-radio-legend | |
| hint | .eq-radio-hint | |
| options | .eq-radio-options | Lays the radios out by orientation. |
| radio | .eq-radio | The wrapping label for one option. |
| input | .eq-radio-input | The native radio with appearance: none. |
| text | .eq-radio-text | |
| label | .eq-radio-label | |
| description | .eq-radio-description |
Keyboard
| Tab | Moves focus into the group, to the checked radio or the first one. Provided by the browser. |
|---|---|
| DownRight | Moves to the next radio and checks it. Provided by the browser. |
| UpLeft | Moves to the previous radio and checks it. Provided by the browser. |
| Space | Checks the focused radio if none is checked. Provided by the browser. |
Notes
- Renders on the server with no client JavaScript; grouping and arrow keys come from radios sharing a name.
- RadioGroup cannot pass name down without client context, so set the same name on each Radio.
- The fieldset and legend give the group its accessible name, so screen readers announce the question before the options.
- Disabling the fieldset disables every radio inside it.
- For bordered option cards with a price, such as a shipping method or a plan, use Plan picker, which is radio cards built on the same native inputs.
- Without an id, a description stays part of the option's accessible name; with one, it is linked by aria-describedby instead. Not yet checked with a screen reader.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/radio.tsx95 lines
import type { ComponentProps, ReactNode } from "react";
import "./radio.css";
/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
return node != null && node !== false && node !== "";
}
export type RadioGroupProps = ComponentProps<"fieldset"> & {
/** The question the options answer. Rendered as the fieldset's legend. */
legend: ReactNode;
/** Help text under the legend. Pass an id too, so it is linked to the group. */
hint?: ReactNode;
/** Lay the options out in a column or a row. */
orientation?: "vertical" | "horizontal";
};
export function RadioGroup({
legend,
hint,
orientation = "vertical",
id,
className,
children,
"aria-describedby": describedBy,
...rest
}: RadioGroupProps) {
const hintId = id != null && present(hint) ? `${id}-hint` : undefined;
const described = [describedBy, hintId].filter(Boolean).join(" ") || undefined;
return (
<fieldset
{...rest}
id={id}
className={["eq-radio-group", className].filter(Boolean).join(" ")}
aria-describedby={described}
>
<legend className="eq-radio-legend">{legend}</legend>
{present(hint) && (
<p className="eq-radio-hint" id={hintId}>
{hint}
</p>
)}
<div className="eq-radio-options" data-orientation={orientation}>
{children}
</div>
</fieldset>
);
}
export type RadioProps = Omit<ComponentProps<"input">, "type"> & {
/** Visible label. Clicking it selects the option. */
label: ReactNode;
/** Secondary line under the label. Pass an id too, so it is read as a description, not as part of the name. */
description?: ReactNode;
};
export function Radio({
label,
description,
id,
className,
"aria-labelledby": labelledBy,
"aria-describedby": describedBy,
...rest
}: RadioProps) {
// With an id, the description is split out of the name; without one it stays inside the label.
const split = id != null && present(description);
const labelId = split ? `${id}-label` : undefined;
const descriptionId = split ? `${id}-description` : undefined;
const described = [describedBy, descriptionId].filter(Boolean).join(" ") || undefined;
return (
<label className={["eq-radio", className].filter(Boolean).join(" ")}>
<input
{...rest}
type="radio"
id={id}
className="eq-radio-input"
aria-labelledby={labelId ?? labelledBy}
aria-describedby={described}
/>
<span className="eq-radio-text">
<span className="eq-radio-label" id={labelId}>
{label}
</span>
{present(description) && (
<span className="eq-radio-description" id={descriptionId}>
{description}
</span>
)}
</span>
</label>
);
}components/equip/radio.css133 lines
@layer equip {
.eq-radio-group {
min-inline-size: 0;
margin: 0;
padding: 0;
border: 0;
font-family: var(--eq-font);
color: var(--eq-ink);
}
.eq-radio-legend {
margin: 0 0 var(--eq-space-1);
padding: 0;
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
color: var(--eq-ink);
}
.eq-radio-hint {
margin: 0;
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
color: var(--eq-ink-2);
}
.eq-radio-options {
display: flex;
flex-direction: column;
gap: var(--eq-space-1);
padding-top: var(--eq-space-1);
}
.eq-radio-options[data-orientation="horizontal"] {
flex-direction: row;
flex-wrap: wrap;
column-gap: var(--eq-space-4);
}
.eq-radio {
display: inline-flex;
align-items: flex-start;
gap: var(--eq-space-2);
/* 2px above and below a 20px line keeps the whole row a 24px target. */
min-height: 24px;
box-sizing: border-box;
padding-block: 2px;
font: var(--eq-weight) var(--eq-text-m) / 20px var(--eq-font);
color: var(--eq-ink);
cursor: pointer;
}
.eq-radio-input {
appearance: none;
box-sizing: border-box;
flex: none;
display: block;
width: 16px;
height: 16px;
margin: 2px 0 0;
border: 1px solid var(--eq-line-strong);
/* Round, unlike the checkbox, so the two never read as the same control. */
border-radius: var(--eq-radius-full);
/* Unchecked, the face is an inset fill rather than the background, so it is painted
once (finishes may give --eq-raised alpha) and the dot has room to grow out of it. */
background: transparent;
box-shadow: inset 0 0 0 7px var(--eq-raised);
cursor: inherit;
transition:
background-color var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease),
box-shadow var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
/* A solid ring around a dot in the solid ink: the dot is the background showing through.
The ring thins from the full face to 4px on the spring, so the dot grows from the centre. */
.eq-radio-input:checked {
border-color: var(--eq-solid);
background: var(--eq-solid-ink);
box-shadow: inset 0 0 0 4px var(--eq-solid);
transition:
background-color var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease),
box-shadow var(--eq-dur-move) var(--eq-spring),
scale var(--eq-dur) var(--eq-ease);
}
/* At 16px the ring gives a little more than a button. */
.eq-radio:active .eq-radio-input:not(:disabled) {
scale: calc(var(--eq-press) - 0.04);
}
@media (hover: hover) {
.eq-radio:hover .eq-radio-input:where(:not(:checked, :disabled)) {
border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
}
}
.eq-radio-input:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/* A required group left empty, once the person has tried to submit. */
.eq-radio .eq-radio-input:user-invalid {
border-color: var(--eq-danger);
}
.eq-radio:has(.eq-radio-input:disabled) {
opacity: 0.45;
cursor: not-allowed;
}
/* A disabled fieldset disables every radio inside without dimming them twice. */
.eq-radio-group:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eq-radio-group:disabled .eq-radio {
opacity: 1;
}
.eq-radio-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.eq-radio-description {
font-size: var(--eq-text-s);
line-height: 18px;
color: var(--eq-ink-2);
}
}