Radio Group
Visible short-list single choice with vertical or horizontal layout.
Radio Group shows a short list of mutually exclusive options as always-visible radios. Pass the shared Option shape from Select (value, label, optional disable). Prefer it when every choice should stay on screen; reach for Select or Multi-Select when the list is long, searchable, or multi-value.
When To Use
- Choose one value from a short, always-visible list (payment method, size, plan tier).
- Prefer Radio Group for ≤ ~7 mutually exclusive options so users can scan without opening a popover.
- Use Select when the list is long, needs search/async, or the user must be able to clear back to empty via the built-in clear affordance.
- Use Multi-Select when the user picks many values.
- Use Checkbox Group when several options can be on at once in a visible list.
Interactions
| Event | Behavior |
|---|---|
| Activate an enabled option | Calls onValueChange with that option’s value (string or number). |
| Click the already-selected option | No change — radios do not toggle off. |
orientation="horizontal" | Options lay out in a wrapping row instead of a vertical stack. |
Option with disable: true | That radio stays non-interactive; siblings remain selectable. |
Group disabled | Every option is non-interactive. |
| Keyboard (focused group) | Arrow keys move selection within the group (Radix / native radio pattern). |
Empty Value & Clearing
null is a valid controlled value — it means no option selected yet. It is not forbidden by radio semantics.
What radios do not provide is a user gesture to return to empty after a choice:
| Situation | Behavior |
|---|---|
value={null} (or omit until first pick) | No radio is checked. Common for required fields before the user chooses. |
| User picks an option | onValueChange always emits a concrete SelectValue — never null. |
| User clicks the selected option again | Stays selected. Unlike a checkbox, there is no uncheck. |
| Need to clear after a selection | Parent sets value back to null (Reset button, form reset, form.setValues). Or use Select which ships a clear affordance. |
In forms, pair required with an empty initial value so submit fails until the user picks one. Prefer seeding a sensible default when “no answer” is not a real product state.
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/radio-group.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/radio-group.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/radio-group.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/radio-group.jsonOr with a namespace: npx shadcn@latest add @f-ui/radio-group.
The CLI pulls radio-group and label from the default shadcn registry, and select from f-ui for the shared Option / SelectValue types.
Usage
import { RadioGroup } from '@/components/f-ui/radio-group/radio-group';
const options = [
{ label: 'Card', value: 'card' },
{ label: 'Wire', value: 'wire' },
{ label: 'Cash', value: 'cash' },
];
<RadioGroup
aria-label="Payment method"
options={options}
value={value}
onValueChange={setValue}
/>Examples
Payment Method
Controlled vertical group for a small enum. Change the selection and watch the readout update.
Selected: card
"use client";
import { useState } from "react";
import { RadioGroup } from "@/components/f-ui/radio-group/radio-group";
const options = [
{ label: "Card", value: "card" },
{ label: "Wire", value: "wire" },
{ label: "Cash", value: "cash" },
];
export function RadioGroupDemo() {
const [value, setValue] = useState<string | number | null>("card");
return (
<div className="space-y-3">
<RadioGroup
aria-label="Payment method"
options={options}
value={value}
onValueChange={setValue}
/>
<p className="text-muted-foreground text-xs">
Selected:{" "}
<span className="text-foreground font-medium">{value ?? "none"}</span>
</p>
</div>
);
}Horizontal Size
Use orientation="horizontal" for compact choices that read left-to-right (sizes, ratings, short codes).
"use client";
import { useState } from "react";
import { RadioGroup } from "@/components/f-ui/radio-group/radio-group";
export function RadioGroupHorizontalDemo() {
const [value, setValue] = useState<string | number | null>("s");
return (
<RadioGroup
aria-label="Size"
orientation="horizontal"
options={[
{ label: "S", value: "s" },
{ label: "M", value: "m" },
{ label: "L", value: "l" },
]}
value={value}
onValueChange={setValue}
/>
);
}Empty Selection and External Clear
Starts at null. After you pick a value, use Reset to null — that clear is parent-owned, not a radio affordance. Compare with Select, which clears from the trigger.
Selected: none (null)
"use client";
import { useState } from "react";
import { RadioGroup } from "@/components/f-ui/radio-group/radio-group";
import { Button } from "@/components/ui/button";
const options = [
{ label: "Card", value: "card" },
{ label: "Wire", value: "wire" },
{ label: "Cash", value: "cash" },
];
/**
* Starts with no selection (`null`). Radios do not offer a user clear —
* Reset is an external action that sets `value` back to `null`.
*/
export function RadioGroupEmptyDemo() {
const [value, setValue] = useState<string | number | null>(null);
return (
<div className="space-y-3">
<RadioGroup
aria-label="Payment method"
options={options}
value={value}
onValueChange={setValue}
/>
<div className="flex items-center gap-3">
<p className="text-muted-foreground text-xs">
Selected:{" "}
<span className="text-foreground font-medium">
{value == null ? "none (null)" : String(value)}
</span>
</p>
<Button
type="button"
variant="outline"
disabled={value == null}
onClick={() => setValue(null)}
>
Reset to null
</Button>
</div>
</div>
);
}Disabled Options and Group
Per-option disable blocks one choice; group disabled locks the whole set.
"use client";
import { RadioGroup } from "@/components/f-ui/radio-group/radio-group";
export function RadioGroupDisabledDemo() {
return (
<div className="space-y-6">
<RadioGroup
aria-label="Plan with disabled option"
options={[
{ label: "Free", value: "free" },
{ label: "Pro", value: "pro" },
{ label: "Enterprise", value: "ent", disable: true },
]}
value="free"
/>
<RadioGroup
aria-label="Disabled group"
disabled
options={[
{ label: "A", value: "a" },
{ label: "B", value: "b" },
]}
value="a"
/>
</div>
);
}Edge Cases & Semantics
| Question | Answer |
|---|---|
Can the value be null? | Yes. Controlled empty = no radio checked. |
Can the user clear to null by clicking? | No. Provide an external Reset / form reset, or use Select. |
Does onValueChange ever emit null? | No — only when the user activates an option. |
undefined vs null for empty? | Prefer null in controlled forms. Omitting value also shows no selection; after a pick, keep the field controlled. |
Value not in options (stale / removed)? | No radio appears checked. Remap or clear to null when options change. |
string vs number option values? | Supported. Radix stores string keys internally; the group remaps back to the original Option.value. |
| Standalone (no FormItem label)? | Pass aria-label (or aria-labelledby) on the group. |
| Formily kind name? | kind="radio" (not radioGroup) + componentProps.options. See Form (Formily) — Choice Groups. |
vs kind="select" / enum? | Select for long / searchable / clearable; Radio for short visible lists. Schema enum still maps to Select by default. |
| vs single Checkbox / Switch? | Those are boolean / on-off — not a short enum list. |
Composition
RadioGroup
└── (per option) item row
├── RadioGroupItem
└── LabelAPI Reference
Props
| Prop | Type | Default |
|---|---|---|
value | SelectValue | null | — |
onValueChange | (value: SelectValue) => void | — |
options | readonly Option[] | — |
orientation | 'vertical' | 'horizontal' | 'vertical' |
disabled | boolean | false |
id | string | auto |
className | string | — |
classNames | Partial<Record<RadioGroupSlot, string>> | — |
aria-label | string | — |
aria-invalid | boolean | — |
aria-required | boolean | — |
aria-describedby | string | — |
Option is { value: SelectValue; label: string; disable?: boolean } from Select. SelectValue is string | number.
value={null} shows no selection. onValueChange never receives null from user interaction.
Slots
| Slot | Applied to |
|---|---|
root | Outer radio group container |
item | Each option row (RadioGroupItem + Label) |
label | Option Label |