RadioGroup
A labelled set of radios where exactly one option wins. Always use the group rather than loose Radio elements: it generates the shared name, applies role="radiogroup", and gives the set an accessible name.
Playground
Change a prop and watch it. The code below follows along.
Live
Open full cardInstall and import
RadioGroup is in @dovetail-ds/react. Install it with React, if you haven't:
npm install @dovetail-ds/react react react-dom
import { RadioGroup } from "@dovetail-ds/react";
Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.
Guidelines
A labelled set of radios where exactly one option wins. Always use the group rather than loose Radio elements: it generates the shared name, applies role="radiogroup", and gives the set an accessible name.
Always preselect
Ship a defaultValue. An empty radio group forces a decision before the user has read the options and cannot be returned to its original state once touched. If no option is a safe default, the question is a Select with a placeholder, not a radio group.
Control at the end
labelPosition="start" puts each label on the left and each radio at the right edge of the row, so the options line up with the group's question instead of indenting. It suits narrow panels and settings lists, where the right edge is where the eye looks for the control.
``jsx <RadioGroup label="Sort by" labelPosition="start" defaultValue="due" options={[ { value: "due", label: "Due date" }, { value: "amount", label: "Amount" }, ]} /> ``
When to use a Select instead
Radios show every option at once, which is their advantage and their cost. Past about five options they crowd the form, so switch to Select. Below three, consider whether the choice is really a Switch.
``jsx <RadioGroup label="Deployment target" defaultValue="staging" options={[ { value: "staging", label: "Staging", hint: "Rebuilt on every merge" }, { value: "canary", label: "Canary", hint: "Five percent of traffic" }, { value: "production", label: "Production" }, ]} /> ``
Props
The typed contract, from RadioGroup.d.ts.
import * as React from "react";
export interface RadioOption {
value: string;
label: React.ReactNode;
hint?: React.ReactNode;
disabled?: boolean;
}
/** A labelled set of radios. Exactly one option is selected. */
export interface RadioGroupProps extends Omit<React.HTMLAttributes<HTMLElement>, "onChange" | "defaultValue"> {
/** The question the set answers. Required. */
label: React.ReactNode;
hint?: React.ReactNode;
error?: React.ReactNode;
required?: boolean;
options: RadioOption[];
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
disabled?: boolean;
/** @default "vertical" */
orientation?: "vertical" | "horizontal";
/** Passed to every option. "start" puts each label on the left and each control at the right edge of the row, so labels line up with the group's question instead of indenting. @default "end" */
labelPosition?: "start" | "end";
/** Shared input name. Generated when omitted. */
name?: string;
}
export declare function RadioGroup(props: RadioGroupProps): React.JSX.Element;
Tokens it reads
Every custom property RadioGroup.jsx resolves, read from the source: 2 semantic. Component tier first, because that is the one to reach for.
- semantic Shared roles. Override one and everything using that role moves with it.
| Token | Tier | Declared as |
|---|---|---|
--dt-space-inline-lg | semantic | var(--dt-dim-6) |
--dt-space-stack-sm | semantic | var(--dt-dim-3) |
Source
React only, no dependencies, every value a token.
import React from "react";
import { Field } from "./Field.jsx";
import { Radio } from "./Radio.jsx";
export function RadioGroup({ label, hint, error, required = false, options = [], value, defaultValue, onChange, disabled = false, orientation = "vertical", labelPosition = "end", name, style, ...rest }) {
const ids = React.useId();
const groupName = name || ids;
const [internal, setInternal] = React.useState(defaultValue);
const selected = value !== undefined ? value : internal;
const pick = (optValue) => {
if (value === undefined) setInternal(optValue);
onChange && onChange(optValue);
};
return (
<Field label={label} hint={hint} error={error} required={required} labelId={`${ids}-label`} messageId={`${ids}-message`} style={style} {...rest}>
<div
role="radiogroup"
aria-labelledby={label ? `${ids}-label` : undefined}
aria-describedby={error || hint ? `${ids}-message` : undefined}
style={{ display: "flex", flexDirection: orientation === "horizontal" ? "row" : "column", flexWrap: "wrap", gap: orientation === "horizontal" ? "var(--dt-space-inline-lg)" : "var(--dt-space-stack-sm)" }}
>
{options.map(opt => (
<Radio
key={opt.value}
name={groupName}
value={opt.value}
label={opt.label}
hint={opt.hint}
checked={selected === opt.value}
disabled={disabled || opt.disabled}
labelPosition={labelPosition}
onChange={() => pick(opt.value)}
/>
))}
</div>
</Field>
);
}
In context
RadioGroup also appears on the forms reference cards, alongside the rest of its family.