# Radio Group

> Let people choose one option from a visible set of mutually exclusive choices.

Source: https://www.honestui.com/docs/components/radio-group

```tsx
import { Label } from "@/components/honest-ui/ui/label"
import { Radio, RadioGroup } from "@/components/honest-ui/ui/radio-group"

export function RadioGroupDemo() {
  return (
    <RadioGroup defaultValue="next">
      <Label>
        <Radio value="next" /> Next.js
      </Label>
      <Label>
        <Radio value="vite" /> Vite
      </Label>
      <Label>
        <Radio value="astro" /> Astro
      </Label>
    </RadioGroup>
  )
}

```

## Overview [#overview]

Use a radio group when the person must pick exactly one option from a small set and every option deserves to be seen before choosing: plans, visibility levels, shipping speeds, sort order. Showing all options at once lets people compare them, which is why radios beat a Select for short lists — reserve Select for long lists or when space is genuinely tight.

Unlike a checkbox, a selection cannot be removed by clicking again. If "none" is a valid answer, it needs its own option; see [Don't do this](#dont-do-this).

## Anatomy [#anatomy]

A group has a container (`role="radiogroup"`), radio items, item labels, optional descriptions, and one shared selection state. The group label names the question ("Shipping speed"); each radio's label names one answer. The labels carry the meaning, because screen readers announce the checked item by its label, not by position.

`RadioGroupItem` is an alias of `Radio`, so both names appear in older code.

## Behavior [#behavior]

**Selection.** Clicking a radio checks it and unchecks its siblings. One radio in a group can also start preselected with `defaultValue`; leave everything unselected when the choice must be deliberate, such as legal or destructive decisions.

**Keyboard.** <kbd>Tab</kbd> moves focus into the checked (or first) radio in the group. Once inside, <kbd>Arrow</kbd> keys move selection through the options, and <kbd>Home</kbd>/<kbd>End</kbd> jump to the first or last item. Arrow keys select as they move, matching native radio behavior.

**Disabled.** `disabled` on the group disables every item; `disabled` on an individual `Radio` removes just that option from interaction while leaving it visible. Disabled items are skipped by keyboard focus and submit no value.

**Invalid.** Setting `aria-invalid="true"` on a `Radio` turns its border toward the danger color, with stronger treatment on keyboard focus so the invalid control remains findable. See the [error state](#error-state) example for the full wiring.

## Accessibility [#accessibility]

Each radio renders a span with `role="radio"` plus a visually hidden native input; the group renders a container with `role="radiogroup"`. The keyboard model above follows the WAI-ARIA radiogroup pattern: one tab stop per group rather than one per option, so long forms stay quick to navigate.

Give the group an accessible name. Wrap it in a `Fieldset` with a `FieldsetLegend`, connect an external heading with `aria-labelledby`, or pass `aria-label` when no visible title exists. Without a group name, screen-reader users hear a list of unrelated choices with no question attached.

Every radio needs a `Label` wrapping it (or linked via `htmlFor`). The rendered circle is 16 px — under comfortable touch-target size — but the wrapping label makes the entire phrase tappable, which is what actually saves touch accuracy.

Colors come from theme tokens, including the dark-mode-specific unchecked fill and shadow adjustments already built into the component. Spacing uses logical flexbox layout, so groups mirror correctly in right-to-left locales.

Long labels wrap within their `Label` instead of pushing siblings out of alignment. Keep each option scannable; move consequences into a description below the group rather than into the label itself.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;radio-group&#x22;]" />
  

  
    
      
        Install the following dependencies:
      

      ```bash
      npm install @base-ui-components/react
      ```

      
        Copy and paste the following code into your project.
      

      ### components/ui/radio-group.tsx

```tsx
"use client"

import { Radio as RadioPrimitive } from "@base-ui-components/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui-components/react/radio-group"

import { cn } from "@/lib/utils"

function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
  return (
    <RadioGroupPrimitive
      data-slot="radio-group"
      className={cn("flex flex-col gap-3", className)}
      {...props}
    />
  )
}

function Radio({ className, ...props }: RadioPrimitive.Root.Props) {
  return (
    <RadioPrimitive.Root
      data-slot="radio"
      className={cn(
        "relative inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-input bg-background bg-clip-padding shadow-xs transition-shadow outline-none before:pointer-events-none before:absolute before:inset-0 before:rounded-full not-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-64 aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/48 dark:bg-clip-border dark:not-data-checked:bg-input/32 dark:not-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/8%)] dark:aria-invalid:ring-destructive/24 [&:is(:disabled,[data-checked],[aria-invalid])]:shadow-none",
        className
      )}
      {...props}
    >
      <RadioPrimitive.Indicator
        data-slot="radio-indicator"
        className="absolute -inset-px flex size-4 items-center justify-center rounded-full before:size-1.5 before:rounded-full before:bg-primary-foreground data-checked:bg-primary data-unchecked:hidden"
      />
    </RadioPrimitive.Root>
  )
}

export { RadioGroup, Radio, Radio as RadioGroupItem }

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import { Label } from "@/components/ui/label";
import { Radio, RadioGroup } from "@/components/ui/radio-group";
```

```tsx
<RadioGroup defaultValue="next">
  <Label>
    <Radio value="next" /> Next.js
  </Label>
  <Label>
    <Radio value="vite" /> Vite
  </Label>
  <Label>
    <Radio value="astro" /> Astro
  </Label>
</RadioGroup>
```

For group labeling and validation, wrap the whole question in `Field` rendered as a `Fieldset` so the legend, error, and description are connected. See the [Field examples](/docs/components/field#examples).

## Don't do this [#dont-do-this]

### Expecting people to deselect [#expecting-people-to-deselect]

```tsx
// Bad
<RadioGroup value={size} onValueChange={setSize}>
  <Label><Radio value="sm" /> Small</Label>
  <Label><Radio value="lg" /> Large</Label>
</RadioGroup>
// ...and elsewhere: onClick={() => setSize(null)} to "clear" the choice
```

```tsx
// Good
<RadioGroup value={size} onValueChange={setSize}>
  <Label><Radio value="sm" /> Small</Label>
  <Label><Radio value="lg" /> Large</Label>
  <Label><Radio value="any" /> No preference</Label>
</RadioGroup>
```

Once a radio is checked, clicking again does nothing — that is how the control communicates "exactly one". Trying to clear a selection from code fights the semantics and leaves keyboard users stranded, because there is no key that deselects either. Make "none of these" an explicit, labeled option instead.

### A group without a question [#a-group-without-a-question]

```tsx
// Bad
<RadioGroup defaultValue="next">
  <Label><Radio value="next" /> Next.js</Label>
  ...
</RadioGroup>

// Good
<Fieldset>
  <FieldsetLegend>Preferred framework</FieldsetLegend>
  <RadioGroup defaultValue="next">
    <Label><Radio value="next" /> Next.js</Label>
    ...
  </RadioGroup>
</Fieldset>
```

Without a group name, assistive technology announces each radio out of context: "Next.js, radio checked" answers no question the user ever heard. Sighted users infer the question from page layout; screen-reader users need it spoken, which is exactly what a legend provides.

### Independent toggles inside a radio group [#independent-toggles-inside-a-radio-group]

```tsx
// Bad
<RadioGroup defaultValue="standard">
  <Label><Radio value="standard" /> Standard shipping</Label>
  <Label><Radio value="express" /> Express shipping</Label>
  <Label><Checkbox /> Add gift wrapping</Label>
</RadioGroup>

// Good
<RadioGroup defaultValue="standard">
  <Label><Radio value="standard" /> Standard shipping</Label>
  <Label><Radio value="express" /> Express shipping</Label>
</RadioGroup>
<Label className="mt-3">
  <Checkbox /> Add gift wrapping
</Label>
```

Mixing controls breaks both contracts at once: arrow keys land on the checkbox as if it were an option, and the checkbox appears to participate in a mutual exclusion it ignores. Selection sets and independent toggles belong in separate structures, visually and semantically.

## Examples [#examples]

Examples cover disabled items, descriptions, card-style layouts, required-choice errors, and form-connected groups.

### Disabled [#disabled]

```tsx
import { Label } from "@/components/honest-ui/ui/label"
import { Radio, RadioGroup } from "@/components/honest-ui/ui/radio-group"

export function RadioGroupDisabledDemo() {
  return (
    <RadioGroup defaultValue="next">
      <Label>
        <Radio value="next" /> Next.js
      </Label>
      <Label>
        <Radio value="vite" disabled /> Vite (disabled)
      </Label>
      <Label>
        <Radio value="astro" /> Astro
      </Label>
    </RadioGroup>
  )
}

```

### With Description [#with-description]

```tsx
import { Label } from "@/components/honest-ui/ui/label"
import { Radio, RadioGroup } from "@/components/honest-ui/ui/radio-group"

export function RadioGroupWithDescriptionDemo() {
  return (
    <RadioGroup defaultValue="r-1">
      <div className="flex items-start gap-2">
        <Radio value="r-1" id="r-1" />
        <div className="flex flex-col gap-1">
          <Label htmlFor="r-1">Free</Label>
          <p className="text-xs text-muted-foreground">
            Basic features for personal use.
          </p>
        </div>
      </div>
      <div className="flex items-start gap-2">
        <Radio value="r-2" id="r-2" />
        <div className="flex flex-col gap-1">
          <Label htmlFor="r-2">Pro</Label>
          <p className="text-xs text-muted-foreground">
            Advanced tools for professionals.
          </p>
        </div>
      </div>
    </RadioGroup>
  )
}

```

### Card Style [#card-style]

```tsx
import { Label } from "@/components/honest-ui/ui/label"
import { Radio, RadioGroup } from "@/components/honest-ui/ui/radio-group"

export function RadioGroupCardDemo() {
  return (
    <RadioGroup defaultValue="r-1">
      <Label className="flex items-start gap-2 rounded-lg border p-3 hover:bg-accent/50 has-data-checked:border-primary/48 has-data-checked:bg-accent/50">
        <Radio value="r-1" />
        <div className="flex flex-col gap-1">
          <p className="text-sm leading-4">Email</p>
          <p className="text-xs text-muted-foreground">
            Receive notifications via email.
          </p>
        </div>
      </Label>
      <Label className="flex items-start gap-2 rounded-lg border p-3 hover:bg-accent/50 has-data-checked:border-primary/48 has-data-checked:bg-accent/50">
        <Radio value="r-2" />
        <div className="flex flex-col gap-1">
          <p className="text-sm leading-4">SMS</p>
          <p className="text-xs text-muted-foreground">
            Receive notifications via text message.
          </p>
        </div>
      </Label>
    </RadioGroup>
  )
}

```

### Error State [#error-state]

Submitting without a choice flags every radio with `aria-invalid`, announces the message via `role="alert"`, and clears the error as soon as an option is picked.

```tsx
"use client"

import * as React from "react"

import { Button } from "@/components/honest-ui/ui/button"
import { Label } from "@/components/honest-ui/ui/label"
import { Radio, RadioGroup } from "@/components/honest-ui/ui/radio-group"

export function RadioGroupErrorDemo() {
  const [value, setValue] = React.useState("")
  const [triedToSubmit, setTriedToSubmit] = React.useState(false)

  const invalid = triedToSubmit && value === ""

  return (
    <form className="grid w-auto gap-3" onSubmit={(event) => {
      event.preventDefault()
      setTriedToSubmit(true)
    }}>
      <fieldset className="grid gap-3">
        <legend className="text-sm font-medium">Shipping speed</legend>
        <RadioGroup
          value={value}
          onValueChange={(next) => {
            setValue(next as string)
            setTriedToSubmit(false)
          }}
        >
          <Label>
            <Radio
              value="standard"
              aria-invalid={invalid}
              aria-describedby={invalid ? "shipping-speed-error" : undefined}
            />
            Standard (4–6 days)
          </Label>
          <Label>
            <Radio
              value="express"
              aria-invalid={invalid}
              aria-describedby={invalid ? "shipping-speed-error" : undefined}
            />
            Express (1–2 days)
          </Label>
        </RadioGroup>
      </fieldset>
      {invalid ? (
        <p id="shipping-speed-error" role="alert" className="text-sm text-destructive">
          Choose a shipping speed before continuing.
        </p>
      ) : null}
      <Button type="submit">Continue</Button>
    </form>
  )
}

```

### Form Integration [#form-integration]

```tsx
"use client";

import * as React from "react";

import { Button } from "@/components/honest-ui/ui/button";
import { Field, FieldLabel } from "@/components/honest-ui/ui/field";
import { Fieldset, FieldsetLegend } from "@/components/honest-ui/ui/fieldset";
import { Form } from "@/components/honest-ui/ui/form";
import { Radio, RadioGroup } from "@/components/honest-ui/ui/radio-group";

export function RadioGroupFormDemo() {
  const [status, setStatus] = React.useState("");

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    setStatus(`Submitted framework: ${formData.get("frameworks")}.`);
  };

  return (
    <Form onSubmit={onSubmit} className="grid max-w-[160px] gap-4">
      <Field
        name="frameworks"
        className="gap-4"
        render={(props) => <Fieldset {...props} />}
      >
        <FieldsetLegend className="text-sm font-medium">
          Frameworks
        </FieldsetLegend>
        <RadioGroup defaultValue="next">
          <FieldLabel>
            <Radio value="next" /> Next.js
          </FieldLabel>
          <FieldLabel>
            <Radio value="vite" /> Vite
          </FieldLabel>
          <FieldLabel>
            <Radio value="astro" /> Astro
          </FieldLabel>
        </RadioGroup>
      </Field>
      <Button type="submit">Save framework</Button>
      <p className="text-sm text-muted-foreground" role="status">
        {status}
      </p>
    </Form>
  );
}

```

## API reference [#api-reference]

`RadioGroup` and `Radio` forward their matching Base UI props. There is no `orientation` prop: put the shared `name`, controlled `value` + `onValueChange` (or `defaultValue`), and `disabled` on the group; put `value`, `disabled`, and validation attributes such as `aria-invalid` on each `Radio`. Give the group a legend or accessible label, and wrap every radio in a `Label`. `RadioGroupItem` is exported as an alias of `Radio`.

The group lays items out vertically with a fixed gap; apply your own layout classes for horizontal rows.

See the [Base UI Radio API](https://base-ui.com/react/components/radio#api-reference).
