Radio
A single option in a choose-one set, usually composed into lists by RadioGroup.
Installation
- Install the package.
npm install @hashintel/ds-components- Import the component.
import { useState } from "react";
import { Radio } from "@hashintel/ds-components";
export const VisibilityPicker = () => {
const [visibility, setVisibility] = useState("workspace");
return (
<>
<Radio
label="Visible to workspace"
name="visibility"
value={visibility === "workspace"}
onChange={() => setVisibility("workspace")}
/>
<Radio
label="Only me"
name="visibility"
value={visibility === "private"}
onChange={() => setVisibility("private")}
/>
</>
);
};When to use
- Do: Use Radio to build a bespoke choose-one layout — options in table rows, cards, or a grid — that RadioGroup's layouts can't express.
When not to use
- Don't: Don't hand-wire a standard stack of options — use RadioGroup, which manages the shared value, group name, and option layout.
- Don't: Don't use a lone radio for an on/off choice — once selected it can't be unselected; use Checkbox or Toggle.
- Don't: Don't present long option lists as radios — beyond roughly 5 options use Select.
- Don't: Don't use radios to switch views or filters that apply instantly — use SegmentedControl.
Examples
Basic
Radio is always controlled: value holds whether this option is selected and
onChange reports selection (see Installation for the group wiring). The
default is md-sized and neutral.
Disabled
A disabled radio keeps its selected state visible but can't be changed. Disable individual options the user's role or plan rules out, and explain the constraint nearby.
Invalid
Set invalid on the options of a group that failed validation — typically a
required choice left unmade. Pair it with visible error text — the red ring
alone doesn't say what's wrong.
Tones
tone colors the selected circle: neutral (default) for everyday forms,
brand for primary flows such as onboarding, success for
confirmation-flavored choices. Unselected radios look the same in every tone.
Sizes
md is the default. Match the surrounding text — xxs–sm for dense chrome such as table rows and filter
panels, lg for prominent choices.
Label placement
labelPlacement puts the label right (default) or left of the circle.
With left, the root also spreads label and circle to opposite edges once you
give it a width via className.
Label alignment
labelAlign sets the circle against a label that wraps onto several lines:
top (default) pins it to the first line, center centers it against the
whole label.
Custom groups
Give every option the same name and derive each value from one piece of
state: the browser then enforces single selection and the arrow keys move both
focus and selection. Prefer RadioGroup unless the layout demands this.
In forms
The selected radio submits its htmlValue (default "on") under name;
nothing submits while the group is unselected. required blocks native
submission until one option in the name group is chosen.
import { useState } from "react";
import { Button, Radio } from "@hashintel/ds-components";
export const ExportScopeForm = () => {
const [scope, setScope] = useState<string | null>(null);
return (
<form action="/api/exports" method="post">
<Radio
label="Current view"
name="scope"
htmlValue="view"
required
value={scope === "view"}
onChange={() => setScope("view")}
/>
<Radio
label="Entire workspace"
name="scope"
htmlValue="workspace"
required
value={scope === "workspace"}
onChange={() => setScope("workspace")}
/>
<Button type="submit" tone="brand">
Start export
</Button>
</form>
);
};Behavior
- RadioGroup renders as one
<label>wrapping a hidden native radio, so clicking anywhere on it selects. Don't nest it inside another label or interactive element. - Selection is one-way from the user's side: clicking a selected radio fires
nothing, and
onChangeonly ever reportstrue. Clearing or switching happens through your state, by selecting a different option. - Radios sharing a
nameget native arrow-key behavior, but each one stays controlled. - Long labels wrap rather than truncate by default
Content
- Name the option, not the action: “Entire workspace”, never “Select entire workspace”. Sentence case, no terminal punctuation.
- Phrase every option in a set with parallel structure — all nouns or all verb phrases — so the differences stand out.
- Keep labels under roughly 5 words and one line; move caveats into nearby helper text.
- For optional choices add a “None” option — a group can't be returned to unselected once the user picks.
Accessibility
Radio renders a native
<input type="radio">.
Grouped under a shared name, and implements the
WAI-ARIA radio group pattern's
interaction model.
What Radio provides
- A visually hidden native
<input type="radio">inside a<label>. - With a shared
name, the native inputs handle Tab and arrow keys.
What you must do
- Always provide
label— without it the input has no accessible name. When the visible label lives elsewhere, passhtmlForIdand point that label'shtmlForat it. - Name the group as a whole: wrap custom groups in
role="radiogroup"with anaria-label, or a<fieldset>with a<legend>. invalidis visual only — put error text next to the group, since nothing is announced otherwise.- Use a shared
nameacross the options so the browser groups the inputs for arrow-key navigation and form submission.
API reference
| Prop | Type | Description |
|---|---|---|
| value | boolean | The input value |
| onChange | (value: boolean) => void | The onChange handler |
| label | ReactNode | An optional label rendered alongside the circle |
| labelPlacement | "left" | "right" default: "right" | Which side of the circle the label is rendered on |
| labelAlign | "top" | "center" default: "top" | Vertical alignment of the circle against the label when it wraps over multiple lines |
| tone | "neutral" | "brand" | "success" default: "neutral" | The tone applied when the radio is selected |
| htmlValue | string default: "on" | An optional value used for native form submissions |
| name | string | The name of the input |
| onFocus | FocusEventHandler<HTMLInputElement> | The onFocus handler |
| onBlur | FocusEventHandler<HTMLInputElement> | The onBlur handler |
| invalid | boolean | Set to show the input as invalid |
| autoFocus | boolean | "never" | Set to true to make the element focused on mount or 'never' to prevent the item being auto-focused |
| disabled | boolean | Set to show the input as disabled |
| required | boolean | Marks the input as required: sets the native required attribute, so assistive technology announces it and form validation blocks empty submits. Purely functional — the input looks unchanged; pair it with Form.Field's required for the visible mark. |
| size | "xxs" | "xs" | "sm" | "md" | "lg" default: "md" | The size (height) of the element |
Was this page helpful?