---
title: Radio Group
description: Radios in a fieldset - the one grouping screen readers announce without help.
source: https://adamjurek.com/components/radio-group
---

## Home


A set of native radio inputs inside a `<fieldset>`, for choosing exactly one
option from a short list. Reach for it whenever the choice needs to be
visible all at once, rather than folded into a select.

<!-- ::start:showcase demo="radio-group" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

A `<fieldset>` with a `<legend>` is the one grouping screen readers announce
without any ARIA added by hand, so the group label rides on markup rather
than a prop wired to `aria-labelledby`. The shared radio `name` falls back to
a generated id when none is given, so two groups on the same page never
merge into one set by accident.

## What it does not do

It does not read well past five or six options - a `NativeSelect` covers that
case in less space. And it is single-choice by construction: more than one
answer allowed at once is `Checkbox`, not a radio no matter how it's styled.

> [!NOTE] Install commands are not written here
> Anything in `packages/ui/registry.ts` gets its TanStack and shadcn commands
> attached automatically, so there is nothing to keep in sync.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/radio-group.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/radio-group.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | content · Forms |
| Files | `radio-group.tsx` |
| Dependencies | None |
| Tags | form, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { RadioGroup } from "@sushindustries/ui";

export function ShippingSpeed() {
	return (
		<RadioGroup
			label="Shipping speed"
			options={[
				{ value: "standard", label: "Standard - 3 to 5 days" },
				{ value: "express", label: "Express - next day" },
			]}
			defaultValue="standard"
		/>
	);
}
```

## What you should see

A fieldset with "Shipping speed" as its legend, and one radio per option
below it, painted in the site's accent colour. "Standard" starts checked
because of `defaultValue`; clicking "Express" moves the dot with no code of
your own, since this example passes neither `value` nor `onChange`.

## If nothing happens

An empty `options` array renders the legend and nothing else - that's
correct. If clicking a radio does nothing, check whether `value` is set
without `onChange`: a controlled group with no handler is frozen on purpose,
the same as any controlled input.


## Guides


## Composing it

It renders a `<fieldset>` with its own `<legend>` - don't wrap it in another
label or fieldset for the group name, that duplicates the announcement
screen readers already get for free.

## When not to use it

For more than five or six options, where a `NativeSelect` reads faster and
takes less vertical space, or when more than one option can be true at
once - that's `Checkbox`, not a radio no matter how it's styled.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `label` | `string` | - | Group label, announced with the set. |
| `options` | `readonly RadioOption[]` | - | Empty renders a legend and nothing else. Values must be unique in the set. |
| `value?` | `string` | - | Controlled selection. Set it and nothing moves until `onChange` comes back. |
| `defaultValue?` | `string` | - | Uncontrolled starting selection. Ignored once `value` is set. |
| `onChange?` | `(value: string) => void` | - | Handed the option's value, not the event. |
| `name?` | `string` | - | The shared radio name. A generated id when absent, so two groups on one page never merge. |

<!-- /generated:api -->

## Notes

`value` and `defaultValue` are mutually exclusive in practice, not just in
naming - once `value` is set the group is controlled, `defaultValue` is
ignored, and nothing moves until `onChange` returns a new `value`. Passing
both is the same trap as a native controlled input: pick one.

`name` only needs setting by hand when two groups must share one radio name
on purpose, or when a specific name matters for form submission. Left
absent, the generated id keeps two `RadioGroup`s on the same page from
merging into one set.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="radio-group" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { RadioGroup } from "@sushindustries/ui";
import { useState } from "react";

export function CheckoutForm() {
	const [speed, setSpeed] = useState("standard");

	return (
		<form className="flex flex-col gap-6">
			<RadioGroup
				label="Shipping speed"
				options={[
					{ value: "standard", label: "Standard - 3 to 5 days" },
					{ value: "express", label: "Express - next day" },
				]}
				value={speed}
				onChange={setSpeed}
			/>
		</form>
	);
}
```
