Skip to main content

Radio Group

A set of checkable buttons (known as radio buttons) where no more than one of the buttons can be checked at a time.

Pair with Field.Set and Field.Legend for grouped label/description/error wiring in forms. RadioGroup keeps its own name prop on RadioGroup.Root:

Density

Choose the row spacing for tables.

import { Field } from "@ngrok/mantle/field";import { RadioGroup } from "@ngrok/mantle/radio-group"; <Field.Set>	<Field.Legend>Density</Field.Legend>	<RadioGroup.Root name="density" defaultValue="compact">		<RadioGroup.Item value="default">			<RadioGroup.Indicator />			<RadioGroup.ItemContent>Default</RadioGroup.ItemContent>		</RadioGroup.Item>		<RadioGroup.Item value="comfortable">			<RadioGroup.Indicator />			<RadioGroup.ItemContent>Comfortable</RadioGroup.ItemContent>		</RadioGroup.Item>		<RadioGroup.Item value="compact">			<RadioGroup.Indicator />			<RadioGroup.ItemContent>Compact</RadioGroup.ItemContent>		</RadioGroup.Item>	</RadioGroup.Root>	<Field.Description>Choose the row spacing for tables.</Field.Description></Field.Set>;

Or render the control on its own:

import { RadioGroup } from "@ngrok/mantle/radio-group"; <RadioGroup.Root aria-label="Density" defaultValue="comfortable">	<RadioGroup.Item className="py-1" value="default">		<RadioGroup.Indicator />		<RadioGroup.ItemContent>Default</RadioGroup.ItemContent>	</RadioGroup.Item>	<RadioGroup.Item className="py-1" value="comfortable" disabled>		<RadioGroup.Indicator />		<RadioGroup.ItemContent>Comfortable</RadioGroup.ItemContent>	</RadioGroup.Item>	<RadioGroup.Item className="py-1" value="compact">		<RadioGroup.Indicator />		<RadioGroup.ItemContent>Compact</RadioGroup.ItemContent>	</RadioGroup.Item></RadioGroup.Root> <RadioGroup.ButtonGroup aria-label="Environment" defaultValue="production">	<RadioGroup.Button value="development">Development</RadioGroup.Button>	<RadioGroup.Button value="staging">Staging</RadioGroup.Button>	<RadioGroup.Button value="production">Production</RadioGroup.Button></RadioGroup.ButtonGroup> <RadioGroup.List aria-label="Density" defaultValue="comfortable">	<RadioGroup.ListItem value="default" disabled>		<RadioGroup.Indicator />		<RadioGroup.ItemContent>			<p className="font-medium text-strong">Default</p>			<p className="text-body">Laborum esse cillum incididunt est dolore.</p>		</RadioGroup.ItemContent>	</RadioGroup.ListItem>	<RadioGroup.ListItem value="comfortable">		<RadioGroup.Indicator />		<RadioGroup.ItemContent>			<p className="font-medium text-strong">Comfortable</p>			<p className="text-body">Ea laboris tempor laborum officia ea adipisicing exercitation.</p>		</RadioGroup.ItemContent>	</RadioGroup.ListItem></RadioGroup.List> <RadioGroup.Root aria-label="Audience" className="grid grid-cols-1 gap-y-6 sm:grid-cols-3 sm:gap-x-4" defaultValue="existing">	<RadioGroup.Card className="flex" value="newsletter">		<div className="flex-1">			<p className="block text-sm font-medium text-strong">Newsletter</p>			<p className="mt-1 flex items-center text-sm text-gray-500">Last message sent an hour ago</p>			<p className="mt-6 text-sm font-medium">621 users</p>		</div>		<RadioGroup.Indicator />	</RadioGroup.Card></RadioGroup.Root>

Rich option layout with Choice

Choice gives each radio option the same indicator-left / title-over-description layout used across mantle, without hand-rolling the flexbox: put RadioGroup.Indicator in Choice.Indicator and the copy in Choice.Content.

import { Choice } from "@ngrok/mantle/choice";import { RadioGroup } from "@ngrok/mantle/radio-group"; <RadioGroup.Root aria-label="Plan" defaultValue="pro">	<RadioGroup.Item value="free">		<Choice.Root>			<Choice.Indicator>				<RadioGroup.Indicator />			</Choice.Indicator>			<Choice.Content>				<Choice.Title>Free</Choice.Title>				<Choice.Description>Up to 3 projects and 1 member.</Choice.Description>			</Choice.Content>		</Choice.Root>	</RadioGroup.Item>	{/* …more items… */}</RadioGroup.Root>;

Use Choice.Title here, not Choice.Label: the whole RadioGroup.Item is the click target, and a <label htmlFor> can't associate with the radio's role="radio" element. Inside any radio item (Item, ListItem, Card, or Button), Choice.Title and Choice.Description register their ids with the radio. The radio's aria-labelledby points at the title, so the option's accessible name is the title alone, and its aria-describedby points at the description. Without that wiring, role="radio" would flatten both texts into one name. A plain RadioGroup.ItemContent has no title or description part to register, so its text still reads as one name; reach for Choice when the option carries helper text.

Give the RadioGroup itself an accessible name (an aria-label, as above, or a Field.Set / Field.Legend wrapper) so the group is announced as a named set rather than an anonymous radiogroup.

To bind the same group into a form, wrap it in a Field.Set / Field.Legend: the legend names the set, and keeping aria-label on RadioGroup.Root names the inner radiogroup role too.

Plan
import { Choice } from "@ngrok/mantle/choice";import { Field } from "@ngrok/mantle/field";import { RadioGroup } from "@ngrok/mantle/radio-group"; <Field.Set>	<Field.Legend>Plan</Field.Legend>	<RadioGroup.Root aria-label="Plan" defaultValue="pro">		<RadioGroup.Item value="free">			<Choice.Root>				<Choice.Indicator>					<RadioGroup.Indicator />				</Choice.Indicator>				<Choice.Content>					<Choice.Title>Free</Choice.Title>					<Choice.Description>Up to 3 projects and 1 member.</Choice.Description>				</Choice.Content>			</Choice.Root>		</RadioGroup.Item>		{/* …more items… */}	</RadioGroup.Root></Field.Set>;

With TanStack Form

Use @tanstack/react-form with zod to keep a radio group controlled. Use Field.Set and Field.Legend because the grouped choices answer one shared question.

Density
import { Button } from "@ngrok/mantle/button";import { Field, toErrorMessages } from "@ngrok/mantle/field";import { RadioGroup } from "@ngrok/mantle/radio-group";import { useForm } from "@tanstack/react-form";import { z } from "zod"; export const formSchema = z.object({	density: z.string().min(1, "Choose a density."),});function Example() {	const defaultValues = {		density: "",	};	const form = useForm({		defaultValues,		validators: {			onSubmit: formSchema,		},		onSubmit: ({ value }) => {			// Handle form submission here		},	}); 	return (		<form			onSubmit={(event) => {				event.preventDefault();				event.stopPropagation();				void form.handleSubmit();			}}		>			<form.Field name="density">				{(field) => (					<Field.Set>						<Field.Legend>Density</Field.Legend>						<RadioGroup.Root							name={field.name}							value={field.state.value}							onChange={field.handleChange}						>							<RadioGroup.Item value="default">								<RadioGroup.Indicator />								<RadioGroup.ItemContent>Default</RadioGroup.ItemContent>							</RadioGroup.Item>							<RadioGroup.Item value="comfortable">								<RadioGroup.Indicator />								<RadioGroup.ItemContent>Comfortable</RadioGroup.ItemContent>							</RadioGroup.Item>							<RadioGroup.Item value="compact">								<RadioGroup.Indicator />								<RadioGroup.ItemContent>Compact</RadioGroup.ItemContent>							</RadioGroup.Item>						</RadioGroup.Root>						<Field.Errors messages={toErrorMessages(field.state.meta.errors)} />					</Field.Set>				)}			</form.Field>			<Button type="submit" appearance="filled" intent="neutral">				Submit			</Button>		</form>	);}

Composition

RadioGroup supports several layout variants. Compose the parts together to build your own:

# Default radiosRadioGroup.Root└── RadioGroup.Item    ├── RadioGroup.Indicator    └── RadioGroup.ItemContent# List layout with descriptionsRadioGroup.List└── RadioGroup.ListItem    ├── RadioGroup.Indicator    └── RadioGroup.ItemContent# Segmented button groupRadioGroup.ButtonGroup└── RadioGroup.Button# Card-style radiosRadioGroup.Root└── RadioGroup.Card    └── RadioGroup.Indicator# Rich option layout (Choice)RadioGroup.Root└── RadioGroup.Item    └── Choice.Root        ├── Choice.Indicator        │   └── RadioGroup.Indicator        └── Choice.Content            ├── Choice.Title            └── Choice.Description

Polymorphism

RadioGroup.ItemContent accepts asChild. When true it renders its single child instead of its default <div>, forwarding class names, data-* attributes, and ref onto that child. Reach for it when the title-and-description block beside a radio needs to be a specific element, such as a <p> or a link wrapping the whole content area. Do not reach for a <label htmlFor>: each item is a div[role="radio"], which for cannot bind, and the item's text content is already its accessible name.

The rest of the family deliberately does not take asChild. RadioGroup.Root, List, and ButtonGroup own the group's roving-focus and arrow-key contract; Item, ListItem, Card, and Button each render a div[role="radio"] with its keyboard handling, and Indicator paints the control itself. When the group has a name, Headless UI renders one hidden <input type="radio"> for form submission. For a richer option layout, compose Choice inside RadioGroup.Item (see Rich option layout with Choice) rather than swapping these elements out.

API Reference

RadioGroup.Root

A group of radio items. Manages state so only one item can be selected at a time. Unstyled and simple.

All props from Headless UI RadioGroup, including:

PropTypeDefaultDescription
value?stringThe controlled value of the selected radio item.
defaultValue?stringThe default value when uncontrolled.
onChange?(value: string) => voidEvent handler called when the selected value changes.
disabled?booleanfalseWhen true, prevents interaction with all items in the group.
name?stringThe name used when using the radio group inside a form.

RadioGroup.Item

A simple radio item. The conventional use-case for basic radio options. Must be a child of RadioGroup.Root. Inside Field.Control, the item picks up aria-invalid, aria-errormessage, and aria-describedby. A Choice.Title inside names it through aria-labelledby, and a Choice.Description describes it through aria-describedby.

PropTypeDefaultDescription
valuestringThe unique value of the radio item.
disabled?booleanWhen true, prevents the user from interacting with the item.

RadioGroup.Indicator

The selection indicator for any radio item. By default, renders a circle that changes color when checked. Use as a child of RadioGroup.Item, RadioGroup.ListItem, or RadioGroup.Card.

Accepts a render-props function (context) => ReactNode as children for custom indicators. The context includes checked, disabled, focus, hover, and autofocus.

RadioGroup.ItemContent

The content wrapper for any radio item. Wrap labels, descriptions, or other content inside.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to render a custom element instead of the default div.

RadioGroup.ButtonGroup

An inline group of radio buttons rendered as a horizontal segmented control. Use RadioGroup.Button as direct children.

Each segment keeps its label on one line and grows to fit it. Keep labels to one or two words. If the labels do not fit the width of the layout, use RadioGroup.Root or RadioGroup.List instead.

Same props as RadioGroup.Root.

RadioGroup.Button

A radio button used inside RadioGroup.ButtonGroup for inline grouped radio options. Inside Field.Control, the item picks up aria-invalid, aria-errormessage, and aria-describedby. A Choice.Title inside names it through aria-labelledby, and a Choice.Description describes it through aria-describedby.

PropTypeDefaultDescription
valuestringThe unique value of the radio button.
disabled?booleanWhen true, prevents the user from interacting with the button.

RadioGroup.List

A group of radio list items with connected borders. Use RadioGroup.ListItem as direct children.

Same props as RadioGroup.Root.

RadioGroup.ListItem

A radio list item used inside RadioGroup.List for connected list-style radio options with borders and padding. Inside Field.Control, the item picks up aria-invalid, aria-errormessage, and aria-describedby. A Choice.Title inside names it through aria-labelledby, and a Choice.Description describes it through aria-describedby.

PropTypeDefaultDescription
valuestringThe unique value of the radio item.
disabled?booleanWhen true, prevents the user from interacting with the item.

RadioGroup.Card

A radio card item with enhanced styling for card-based radio options. Inside Field.Control, the item picks up aria-invalid, aria-errormessage, and aria-describedby. A Choice.Title inside names it through aria-labelledby, and a Choice.Description describes it through aria-describedby.

PropTypeDefaultDescription
valuestringThe unique value of the radio card.
disabled?booleanWhen true, prevents the user from interacting with the card.

RadioGroup.InputSandbox

A sandbox container for input elements composed within radio group items. A click inside it still selects the radio, then focus returns to the input. Keys other than Enter and Tab stop here, so typing does not move the selection. When the item is disabled or unchecked, the input leaves the tab order.

PropTypeDefaultDescription
childrenReactNodeA single input element to render inside the sandbox.