f-ui
Components

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

EventBehavior
Activate an enabled optionCalls onValueChange with that option’s value (string or number).
Click the already-selected optionNo change — radios do not toggle off.
orientation="horizontal"Options lay out in a wrapping row instead of a vertical stack.
Option with disable: trueThat radio stays non-interactive; siblings remain selectable.
Group disabledEvery 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:

SituationBehavior
value={null} (or omit until first pick)No radio is checked. Common for required fields before the user chooses.
User picks an optiononValueChange always emits a concrete SelectValue — never null.
User clicks the selected option againStays selected. Unlike a checkbox, there is no uncheck.
Need to clear after a selectionParent 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.json
npx shadcn@latest add https://ui.isaacfei.com/r/radio-group.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/r/radio-group.json
bun x shadcn@latest add https://ui.isaacfei.com/r/radio-group.json

Or 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

QuestionAnswer
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
    └── Label

API Reference

Props

PropTypeDefault
valueSelectValue | null
onValueChange(value: SelectValue) => void
optionsreadonly Option[]
orientation'vertical' | 'horizontal''vertical'
disabledbooleanfalse
idstringauto
classNamestring
classNamesPartial<Record<RadioGroupSlot, string>>
aria-labelstring
aria-invalidboolean
aria-requiredboolean
aria-describedbystring

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

SlotApplied to
rootOuter radio group container
itemEach option row (RadioGroupItem + Label)
labelOption Label

On this page