---
title: Radio Group
description: Choose one option from a related set.
group: Components
order: 54
---

A set of radio buttons where only one option can be selected.

```tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

export function Example() {
  return (
    <RadioGroup defaultValue="comfortable">
      <label className="flex items-center gap-2.5 text-sm tracking-wide">
        <RadioGroupItem value="default" id="r-basic-default" />
        Default
      </label>
      <label className="flex items-center gap-2.5 text-sm tracking-wide">
        <RadioGroupItem value="comfortable" id="r-basic-comfortable" />
        Comfortable
      </label>
      <label className="flex items-center gap-2.5 text-sm tracking-wide">
        <RadioGroupItem value="compact" id="r-basic-compact" />
        Compact
      </label>
    </RadioGroup>
  )
}
```

## Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add radio-group
```

## Usage

```tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
```

```tsx
<RadioGroup defaultValue="option-one">
  <label className="flex items-center gap-2.5 text-sm tracking-wide">
    <RadioGroupItem value="option-one" id="option-one" />
    Option A
  </label>
  <label className="flex items-center gap-2.5 text-sm tracking-wide">
    <RadioGroupItem value="option-two" id="option-two" />
    Option B
  </label>
</RadioGroup>
```

## Composition

```tree
RadioGroup
├── RadioGroupItem
└── RadioGroupItem
```

## Basic

Simple labeled options. Use `defaultValue` to set the initial uncontrolled value.

```tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

export function Example() {
  return (
    <RadioGroup defaultValue="comfortable">
      <label className="flex items-center gap-2.5 text-sm tracking-wide">
        <RadioGroupItem value="default" id="r-basic-default" />
        Default
      </label>
      <label className="flex items-center gap-2.5 text-sm tracking-wide">
        <RadioGroupItem value="comfortable" id="r-basic-comfortable" />
        Comfortable
      </label>
      <label className="flex items-center gap-2.5 text-sm tracking-wide">
        <RadioGroupItem value="compact" id="r-basic-compact" />
        Compact
      </label>
    </RadioGroup>
  )
}
```

## Description

Add a title and muted helper text when a short label is not enough.

```tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

export function Example() {
  return (
    <RadioGroup defaultValue="comfortable">
      <label className="flex items-start gap-2.5">
        <RadioGroupItem
          value="default"
          id="r-desc-default"
          className="mt-0.5"
        />
        <span className="grid gap-1">
          <span className="text-sm font-medium tracking-wide">Default</span>
          <span className="text-sm text-muted-foreground">
            Standard spacing for most use cases.
          </span>
        </span>
      </label>
      <label className="flex items-start gap-2.5">
        <RadioGroupItem
          value="comfortable"
          id="r-desc-comfortable"
          className="mt-0.5"
        />
        <span className="grid gap-1">
          <span className="text-sm font-medium tracking-wide">
            Comfortable
          </span>
          <span className="text-sm text-muted-foreground">
            More space between elements.
          </span>
        </span>
      </label>
      <label className="flex items-start gap-2.5">
        <RadioGroupItem
          value="compact"
          id="r-desc-compact"
          className="mt-0.5"
        />
        <span className="grid gap-1">
          <span className="text-sm font-medium tracking-wide">Compact</span>
          <span className="text-sm text-muted-foreground">
            Minimal spacing for dense layouts.
          </span>
        </span>
      </label>
    </RadioGroup>
  )
}
```

## Card

Wrap each option in a `Card` for a larger choice surface. Control selection with `value` and `onValueChange`; the selected card uses a filled surface.

```tsx
import * as React from "react"
import { Card, CardContent } from "@/components/ui/card"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { cn } from "@/lib/utils"

export function Example() {
  const [plan, setPlan] = React.useState("pro")

  return (
    <RadioGroup value={plan} onValueChange={setPlan} className="w-full gap-3">
      <label className="block w-full cursor-pointer">
        <Card className={cn("py-0", plan !== "plus" && "bg-transparent")}>
          <CardContent className="flex items-start gap-2.5 px-2.5 py-2.5">
            <RadioGroupItem value="plus" id="r-card-plus" className="mt-0.5" />
            <span className="grid gap-1">
              <span className="text-sm font-medium tracking-wide">Plus</span>
              <span className="text-sm text-muted-foreground">
                For individuals and small teams.
              </span>
            </span>
          </CardContent>
        </Card>
      </label>
      <label className="block w-full cursor-pointer">
        <Card className={cn("py-0", plan !== "pro" && "bg-transparent")}>
          <CardContent className="flex items-start gap-2.5 px-2.5 py-2.5">
            <RadioGroupItem value="pro" id="r-card-pro" className="mt-0.5" />
            <span className="grid gap-1">
              <span className="text-sm font-medium tracking-wide">Pro</span>
              <span className="text-sm text-muted-foreground">
                For growing businesses.
              </span>
            </span>
          </CardContent>
        </Card>
      </label>
      <label className="block w-full cursor-pointer">
        <Card
          className={cn("py-0", plan !== "enterprise" && "bg-transparent")}
        >
          <CardContent className="flex items-start gap-2.5 px-2.5 py-2.5">
            <RadioGroupItem
              value="enterprise"
              id="r-card-enterprise"
              className="mt-0.5"
            />
            <span className="grid gap-1">
              <span className="text-sm font-medium tracking-wide">
                Enterprise
              </span>
              <span className="text-sm text-muted-foreground">
                For large teams and enterprises.
              </span>
            </span>
          </CardContent>
        </Card>
      </label>
    </RadioGroup>
  )
}
```

## Fieldset

Group radios in a native `fieldset` and `legend` with helper text.

```tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

export function Example() {
  return (
    <fieldset className="m-0 grid w-full gap-3 border-0 p-0">
      <legend className="float-left w-full p-0">
        <span className="grid gap-1 pb-3">
          <span className="text-sm font-medium tracking-wide text-foreground">
            Subscription Plan
          </span>
          <span className="text-sm font-normal text-muted-foreground">
            Yearly and lifetime plans offer significant savings.
          </span>
        </span>
      </legend>
      <RadioGroup defaultValue="yearly">
        <label className="flex items-center gap-2.5 text-sm tracking-wide">
          <RadioGroupItem value="monthly" id="r-fieldset-monthly" />
          Monthly ($9.99/month)
        </label>
        <label className="flex items-center gap-2.5 text-sm tracking-wide">
          <RadioGroupItem value="yearly" id="r-fieldset-yearly" />
          Yearly ($99.99/year)
        </label>
        <label className="flex items-center gap-2.5 text-sm tracking-wide">
          <RadioGroupItem value="lifetime" id="r-fieldset-lifetime" />
          Lifetime ($299.99)
        </label>
      </RadioGroup>
    </fieldset>
  )
}
```

## Disabled

`disabled` on `RadioGroup` locks every item. Dim the labels so the list reads as inactive.

```tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

export function Example() {
  return (
    <RadioGroup defaultValue="option-one" disabled>
      <label className="flex items-center gap-2.5 text-sm tracking-wide opacity-60">
        <RadioGroupItem value="option-one" id="r-disabled-one" />
        Option One
      </label>
      <label className="flex items-center gap-2.5 text-sm tracking-wide opacity-60">
        <RadioGroupItem value="option-two" id="r-disabled-two" />
        Option Two
      </label>
      <label className="flex items-center gap-2.5 text-sm tracking-wide opacity-60">
        <RadioGroupItem value="option-three" id="r-disabled-three" />
        Option Three
      </label>
    </RadioGroup>
  )
}
```

## Invalid

Pass `aria-invalid` to the items for error styling, and use destructive text for their labels.

```tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

export function Example() {
  return (
    <div className="grid w-full gap-3">
      <div className="grid gap-1">
        <p className="text-sm font-medium tracking-wide text-destructive">
          Notification preferences
        </p>
        <p className="text-sm text-muted-foreground">
          Choose how you want to receive notifications.
        </p>
      </div>
      <RadioGroup defaultValue="email">
        <label className="flex items-center gap-2.5 text-sm tracking-wide text-destructive">
          <RadioGroupItem value="email" id="r-invalid-email" aria-invalid />
          Email only
        </label>
        <label className="flex items-center gap-2.5 text-sm tracking-wide text-destructive">
          <RadioGroupItem value="sms" id="r-invalid-sms" aria-invalid />
          SMS only
        </label>
        <label className="flex items-center gap-2.5 text-sm tracking-wide text-destructive">
          <RadioGroupItem value="both" id="r-invalid-both" aria-invalid />
          Both Email & SMS
        </label>
      </RadioGroup>
    </div>
  )
}
```

## API Reference

### RadioGroup

| Prop | Type | Default |
| --- | --- | --- |
| value | string | — |
| defaultValue | string | "" |
| onValueChange | (value: string) => void | — |
| disabled | boolean | — |
| orientation | "vertical" \| "horizontal" | "vertical" |
| className | string | — |

```tsx
<RadioGroup defaultValue="a">
  <RadioGroupItem value="a" />
  <RadioGroupItem value="b" />
</RadioGroup>
```

### RadioGroupItem

Must be used inside `RadioGroup`. Arrow keys move selection within the group. Pass `aria-invalid` for validation styling.

| Prop | Type | Default |
| --- | --- | --- |
| value | string | — |
| disabled | boolean | — |
| className | string | — |

```tsx
<RadioGroupItem value="a" />
```

## Source

```tsx
"use client";

import {
  createContext,
  useCallback,
  useContext,
  useId,
  useMemo,
  useRef,
  useState,
  type ButtonHTMLAttributes,
  type HTMLAttributes,
  type KeyboardEvent,
  type MouseEvent,
} from "react";
import { cn } from "@/lib/utils";

type RadioGroupContextValue = {
  value: string;
  disabled?: boolean;
  onValueChange: (value: string) => void;
  register: (value: string, el: HTMLButtonElement | null) => void;
};

const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);

function useRadioGroup() {
  const context = useContext(RadioGroupContext);
  if (!context) {
    throw new Error("RadioGroupItem must be used within <RadioGroup>");
  }
  return context;
}

type RadioGroupProps = Omit<
  HTMLAttributes<HTMLDivElement>,
  "defaultValue" | "onChange"
> & {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  disabled?: boolean;
  orientation?: "vertical" | "horizontal";
};

function RadioGroup({
  className,
  value: valueProp,
  defaultValue = "",
  onValueChange,
  disabled,
  orientation = "vertical",
  children,
  onKeyDown,
  ...props
}: RadioGroupProps) {
  const controlled = valueProp !== undefined;
  const [uncontrolled, setUncontrolled] = useState(defaultValue);
  const value = controlled ? valueProp : uncontrolled;
  const itemsRef = useRef(new Map<string, HTMLButtonElement>());

  const register = useCallback(
    (itemValue: string, el: HTMLButtonElement | null) => {
      if (el) itemsRef.current.set(itemValue, el);
      else itemsRef.current.delete(itemValue);
    },
    [],
  );

  const handleChange = useCallback(
    (next: string) => {
      if (!controlled) setUncontrolled(next);
      onValueChange?.(next);
    },
    [controlled, onValueChange],
  );

  const context = useMemo(
    () => ({
      value,
      disabled,
      onValueChange: handleChange,
      register,
    }),
    [value, disabled, handleChange, register],
  );

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented) return;

    const keys = [
      "ArrowDown",
      "ArrowUp",
      "ArrowLeft",
      "ArrowRight",
      "Home",
      "End",
    ];
    if (!keys.includes(event.key)) return;

    const items = Array.from(itemsRef.current.entries()).filter(
      ([, el]) => !el.disabled,
    );
    if (!items.length) return;

    const currentIndex = items.findIndex(([itemValue]) => itemValue === value);
    let nextIndex = currentIndex;

    if (event.key === "Home") nextIndex = 0;
    else if (event.key === "End") nextIndex = items.length - 1;
    else if (event.key === "ArrowDown" || event.key === "ArrowRight") {
      nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
    } else if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
      nextIndex =
        currentIndex < 0
          ? items.length - 1
          : (currentIndex - 1 + items.length) % items.length;
    }

    event.preventDefault();
    const [nextValue, el] = items[nextIndex]!;
    handleChange(nextValue);
    el.focus();
  }

  return (
    <RadioGroupContext.Provider value={context}>
      <div
        role="radiogroup"
        data-slot="radio-group"
        data-orientation={orientation}
        aria-orientation={orientation}
        aria-disabled={disabled || undefined}
        className={cn(
          "grid gap-3",
          orientation === "horizontal" && "auto-cols-max grid-flow-col",
          className,
        )}
        onKeyDown={handleKeyDown}
        {...props}
      >
        {children}
      </div>
    </RadioGroupContext.Provider>
  );
}

type RadioGroupItemProps = Omit<
  ButtonHTMLAttributes<HTMLButtonElement>,
  "value" | "children" | "role"
> & {
  value: string;
};

function RadioGroupItem({
  className,
  value,
  disabled,
  id,
  onClick,
  ...props
}: RadioGroupItemProps) {
  const group = useRadioGroup();
  const generatedId = useId();
  const itemId = id ?? generatedId;
  const checked = group.value === value;
  const isDisabled = Boolean(disabled || group.disabled);

  function handleClick(event: MouseEvent<HTMLButtonElement>) {
    onClick?.(event);
    if (event.defaultPrevented || isDisabled) return;
    group.onValueChange(value);
  }

  return (
    <button
      type="button"
      role="radio"
      id={itemId}
      data-slot="radio-group-item"
      data-state={checked ? "checked" : "unchecked"}
      aria-checked={checked}
      disabled={isDisabled}
      value={value}
      ref={(el) => group.register(value, el)}
      className={cn(
        "peer relative inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-input bg-transparent transition-colors duration-200 ease-out disabled:pointer-events-none disabled:opacity-40",
        "data-[state=checked]:border-primary",
        "aria-invalid:border-destructive aria-invalid:bg-destructive/5",
        className,
      )}
      {...props}
      onClick={handleClick}
    >
      <span
        data-slot="radio-group-indicator"
        className={cn(
          "absolute inset-[3px] rounded-full bg-primary transition-transform duration-200 ease-out",
          checked ? "scale-100" : "scale-0",
        )}
        aria-hidden
      />
    </button>
  );
}

export { RadioGroup, RadioGroupItem };
```
