# Radio Group

A set of radio buttons where no more than one can be checked at a time.

```tsx
import * as stylex from "@stylexjs/stylex"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { HStack } from "@/components/ui/stack"

const styles = stylex.create({
  group: { width: "fit-content" },
})

export default function RadioGroupDemo() {
  return (
    <RadioGroup defaultValue="comfortable" xstyle={styles.group}>
      <HStack gap={3} align="center">
        <RadioGroupItem value="default" id="density-default" />
        <Label htmlFor="density-default">Default</Label>
      </HStack>
      <HStack gap={3} align="center">
        <RadioGroupItem value="comfortable" id="density-comfortable" />
        <Label htmlFor="density-comfortable">Comfortable</Label>
      </HStack>
      <HStack gap={3} align="center">
        <RadioGroupItem value="compact" id="density-compact" />
        <Label htmlFor="density-compact">Compact</Label>
      </HStack>
    </RadioGroup>
  )
}
```

## Installation

1. Install the following dependencies:

   ```bash
   npm install @base-ui/react @stylexjs/stylex clsx
   ```

2. Set up the token layer and the StyleX compiler, if you have not already. See
   [Installation](https://carbon.style/docs/installation), and paste `states.ts` and
   `state-styles.ts` from the same page. The radio group imports both. The examples caption
   their buttons with a [Label](https://carbon.style/docs/components/label) or a
   [Field](https://carbon.style/docs/components/field).

   Paste `field-state.ts` from the same installation page.

3. Copy and paste the following code into your project.

   ```tsx title="components/ui/radio-group.tsx"
   "use client"

   import { use, type CSSProperties } from "react"
   import { Radio as RadioPrimitive } from "@base-ui/react/radio"
   import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
   import * as stylex from "@stylexjs/stylex"
   import type { StyleXStyles } from "@stylexjs/stylex"
   import { clsx } from "clsx"

   import { stateStyles } from "@/lib/state-styles"
   import { FieldStateContext } from "@/lib/field-state"
   import { states } from "@/theme/states"
   import { color, duration, ease, radius, space } from "@/theme/tokens.stylex"

   const styles = stylex.create({
     group: {
       display: "grid",
       gap: space.s2,
       width: "100%",
     },
     item: {
       position: "relative",
       display: "flex",
       alignItems: "center",
       justifyContent: "center",
       flexShrink: 0,
       width: space.s4,
       height: space.s4,
       borderRadius: radius.full,
       borderWidth: 1,
       borderStyle: "solid",
       transitionProperty: "color, background-color, border-color, box-shadow",
       transitionDuration: duration.color,
       transitionTimingFunction: ease.standard,
       "::after": {
         content: '""',
         position: "absolute",
         insetInline: `calc(-1 * ${space.s3})`,
         insetBlock: `calc(-1 * ${space.s2})`,
       },
     },
     indicator: {
       width: space.s1_5,
       height: space.s1_5,
       borderRadius: radius.full,
       boxShadow: `0 1px 1px ${color.buttonDrop}`,
       backgroundColor: color.current,
       transitionProperty: "opacity, transform",
       transitionDuration: {
         default: duration.color,
         "@media (prefers-reduced-motion: reduce)": "0s",
       },
       transitionTimingFunction: ease.standard,
     },
     indicatorHidden: { opacity: 0, transform: "scale(0.6)" },
   })

   const radioGroupStyles = {
     group: styles.group,
     item: styles.item,
     key: [states.key, states.keyChoice],
     surface: states.keySurface,
     checked: states.keyChecked,
     disabled: states.disabled,
     invalid: states.invalidRing,
   }

   type StyleProps = {
     xstyle?: StyleXStyles
     className?: string
   }

   type StateOverrides = StyleProps & { style?: CSSProperties }

   type RadioGroupProps = Omit<RadioGroupPrimitive.Props, "className"> & StyleProps

   function RadioGroup({ xstyle, className, ...props }: RadioGroupProps) {
     const sx = stylex.props(styles.group, xstyle)

     return (
       <RadioGroupPrimitive
         data-slot="radio-group"
         {...sx}
         {...props}
         className={clsx(sx.className, className)}
       />
     )
   }

   type RadioGroupItemProps = Omit<RadioPrimitive.Root.Props, "className" | "style"> & StateOverrides

   function RadioGroupItem({ xstyle, style, className, ...props }: RadioGroupItemProps) {
     const fieldState = use(FieldStateContext)
     return (
       <RadioPrimitive.Root
         data-slot="radio-group-item"
         {...props}
         {...stateStyles(
           (state) => [
             styles.item,
             states.key,
             states.keyChoice,
             states.focusRing,
             states.keySurface,
             (state.valid === false ||
               fieldState?.valid === false ||
               props["aria-invalid"] === true ||
               props["aria-invalid"] === "true") &&
               states.invalidRing,
             state.checked && states.keyChecked,
             state.disabled && states.disabled,
             xstyle,
           ],
           { className, style },
         )}
       >
         <RadioPrimitive.Indicator
           data-slot="radio-group-indicator"
           {...stateStyles((state) => [
             styles.indicator,
             (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
               styles.indicatorHidden,
           ])}
         />
       </RadioPrimitive.Root>
     )
   }

   export { RadioGroup, RadioGroupItem, radioGroupStyles }
   ```

4. Update the import paths to match your project setup.

## Usage

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

```tsx
<RadioGroup defaultValue="comfortable">
  <HStack gap={3} align="center">
    <RadioGroupItem value="comfortable" id="r1" />
    <Label htmlFor="r1">Comfortable</Label>
  </HStack>
</RadioGroup>
```

`RadioGroup` is a grid that fills its container and stacks its children. Match
each label's `htmlFor` to its button's `id`, or wrap the button in the label.

```text
RadioGroup
├── RadioGroupItem
└── RadioGroupItem
```

## Selected value

Use `defaultValue` for an uncontrolled group, or `value` and `onValueChange` to
control it. `value` is the `value` of the selected `RadioGroupItem`, which can
be any type.

```tsx
const [density, setDensity] = useState("comfortable")

return (
  <RadioGroup value={density} onValueChange={setDensity}>
    …
  </RadioGroup>
)
```

## Description

Put a `Field` around each button and a `FieldContent` beside it to add a line
of helper text under the label.

```tsx
import * as stylex from "@stylexjs/stylex"
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const styles = stylex.create({
  group: { maxWidth: "24rem" },
})

const densities = [
  { id: "default", title: "Default", description: "Standard spacing for most use cases." },
  { id: "comfortable", title: "Comfortable", description: "More space between elements." },
  { id: "compact", title: "Compact", description: "Minimal spacing for dense layouts." },
]

export default function RadioGroupDescription() {
  return (
    <RadioGroup defaultValue="comfortable" xstyle={styles.group}>
      {densities.map((density) => (
        <Field key={density.id} orientation="horizontal">
          <RadioGroupItem value={density.id} id={`desc-${density.id}`} />
          <FieldContent>
            <FieldLabel>{density.title}</FieldLabel>
            <FieldDescription>{density.description}</FieldDescription>
          </FieldContent>
        </Field>
      ))}
    </RadioGroup>
  )
}
```

## Choice card

Wrap a whole `Field` in a `FieldLabel` to turn the option into a card, and pass
`checked` to mark the selected one.

```tsx
"use client"

import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const styles = stylex.create({
  group: { maxWidth: "24rem" },
})

const plans = [
  { id: "plus", title: "Plus", description: "For individuals and small teams." },
  { id: "pro", title: "Pro", description: "For growing businesses." },
  { id: "enterprise", title: "Enterprise", description: "For large teams and enterprises." },
]

export default function RadioGroupChoiceCard() {
  const [plan, setPlan] = useState("plus")

  return (
    <RadioGroup value={plan} onValueChange={setPlan} xstyle={styles.group}>
      {plans.map((entry) => (
        <FieldLabel key={entry.id} htmlFor={`plan-${entry.id}`} checked={plan === entry.id}>
          <Field orientation="horizontal">
            <FieldContent>
              <FieldTitle>{entry.title}</FieldTitle>
              <FieldDescription>{entry.description}</FieldDescription>
            </FieldContent>
            <RadioGroupItem value={entry.id} id={`plan-${entry.id}`} />
          </Field>
        </FieldLabel>
      ))}
    </RadioGroup>
  )
}
```

> **Note**
>
> `FieldLabel` cannot read the button's state, so `checked` is a prop you set from
> the same value you give `RadioGroup`. That makes the card a controlled group.

## Fieldset

Use `Fieldset` and `FieldsetLegend` to caption the group as a whole, and give the
group a `name` to submit it with a form.

```tsx
import * as stylex from "@stylexjs/stylex"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { FieldsetLegend, Fieldset } from "@/components/ui/fieldset"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const styles = stylex.create({
  fieldset: { maxWidth: "20rem" },
  label: { fontWeight: 400 },
})

const plans = [
  { id: "monthly", label: "Monthly ($9.99/month)" },
  { id: "yearly", label: "Yearly ($99.99/year)" },
  { id: "lifetime", label: "Lifetime ($299.99)" },
]

export default function RadioGroupFieldset() {
  return (
    <Fieldset xstyle={styles.fieldset}>
      <FieldsetLegend variant="label">Subscription plan</FieldsetLegend>
      <FieldDescription>Yearly and lifetime plans offer significant savings.</FieldDescription>
      <RadioGroup defaultValue="monthly" name="subscription-plan">
        {plans.map((plan) => (
          <Field key={plan.id} orientation="horizontal">
            <RadioGroupItem value={plan.id} id={`billing-${plan.id}`} />
            <FieldLabel xstyle={styles.label}>{plan.label}</FieldLabel>
          </Field>
        ))}
      </RadioGroup>
    </Fieldset>
  )
}
```

## Disabled

Set `disabled` on `Field` to disable its control and dim its label, or on a bare control directly.

```tsx
import * as stylex from "@stylexjs/stylex"
import { Field, FieldLabel } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const styles = stylex.create({
  group: { width: "fit-content" },
  label: { fontWeight: 400 },
})

export default function RadioGroupDisabled() {
  return (
    <RadioGroup defaultValue="standard" xstyle={styles.group}>
      <Field orientation="horizontal" disabled>
        <RadioGroupItem value="free" id="shipping-free" />
        <FieldLabel xstyle={styles.label}>Free (unavailable)</FieldLabel>
      </Field>
      <Field orientation="horizontal">
        <RadioGroupItem value="standard" id="shipping-standard" />
        <FieldLabel xstyle={styles.label}>Standard</FieldLabel>
      </Field>
      <Field orientation="horizontal">
        <RadioGroupItem value="express" id="shipping-express" />
        <FieldLabel xstyle={styles.label}>Express</FieldLabel>
      </Field>
    </RadioGroup>
  )
}
```

## Invalid

Set `invalid` on `Field` to style its control, or `aria-invalid` on a bare control. The examples show both.

```tsx
import { Field, FieldLabel, FieldError } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { HStack, VStack } from "@/components/ui/stack"
const channels = [
  { id: "email", label: "Email only" },
  { id: "sms", label: "SMS only" },
  { id: "both", label: "Email and SMS" },
]
function Options({ bare = false }: { bare?: boolean }) {
  return (
    <RadioGroup aria-label={bare ? "Notification preferences" : undefined}>
      {channels.map((channel) => (
        <HStack key={channel.id} gap={2} align="center">
          <RadioGroupItem
            value={channel.id}
            id={`${bare ? "bare" : "field"}-notify-${channel.id}`}
            aria-invalid={bare || undefined}
          />
          <Label htmlFor={`${bare ? "bare" : "field"}-notify-${channel.id}`}>{channel.label}</Label>
        </HStack>
      ))}
    </RadioGroup>
  )
}
export default function RadioGroupInvalid() {
  return (
    <VStack gap={5}>
      <Options bare />
      <Field invalid>
        <FieldLabel>Notification preferences</FieldLabel>
        <Options />
        <FieldError>Pick a notification channel to continue.</FieldError>
      </Field>
    </VStack>
  )
}
```

## API reference

Pass `render` to replace a part's element or compose it with another component.

Both parts wrap the Base UI `RadioGroup` and `Radio` primitives and accept
their props. See the [Base UI reference](https://base-ui.com/react/components/radio-group).
Each part also takes `xstyle` and `className`.

### RadioGroup

| Prop            | Type                              | Default |
| --------------- | --------------------------------- | ------- |
| `value`         | `Value`                           | —       |
| `defaultValue`  | `Value`                           | —       |
| `onValueChange` | `(value: Value, details) => void` | —       |
| `name`          | `string`                          | —       |
| `disabled`      | `boolean`                         | `false` |
| `readOnly`      | `boolean`                         | `false` |
| `required`      | `boolean`                         | `false` |
| `form`          | `string`                          | —       |
| `xstyle`        | `StyleXStyles`                    | —       |
| `className`     | `string`                          | —       |

The root is a `<div role="radiogroup">`. Arrow keys move between the buttons
and select as they go, and the group is one tab stop.

### RadioGroupItem

| Prop           | Type                    | Default |
| -------------- | ----------------------- | ------- |
| `value`        | `Value`                 | —       |
| `disabled`     | `boolean`               | `false` |
| `readOnly`     | `boolean`               | `false` |
| `required`     | `boolean`               | `false` |
| `aria-invalid` | `boolean`               | —       |
| `id`           | `string`                | —       |
| `inputRef`     | `Ref<HTMLInputElement>` | —       |
| `xstyle`       | `StyleXStyles`          | —       |
| `style`        | `CSSProperties`         | —       |
| `className`    | `string`                | —       |

The item is a `<span role="radio">`. Base UI renders a hidden `<input>` beside
it that carries `id`, `name` and `value`. A label's `htmlFor` and a form
submission reach the item through that input.

### radioGroupStyles

The style maps behind the component. Spread them onto any element.

| Key        | What it is                                  |
| ---------- | ------------------------------------------- |
| `group`    | The grid and its gap                        |
| `item`     | The circle: size, border width, hit area    |
| `key`      | The raised key's colours at a choice's size |
| `surface`  | The raised shadow, kept through focus       |
| `checked`  | The glossy primary fill and inverted dot    |
| `disabled` | The dimmed, non-interactive state           |
| `invalid`  | The destructive border and ring             |

`item` has no colour and no focus ring. Everything else comes from `states`
in `theme/states.ts`, the same key the checkbox uses. Merge `key`, then
`states.focusRing`, then `surface`. Merge `invalid` before `checked`, so a
selected invalid button keeps the primary fill and shows only the destructive
ring.

### Data attributes

| Attribute                                         | On         | Effect                                             |
| ------------------------------------------------- | ---------- | -------------------------------------------------- |
| `data-slot="radio-group"`                         | the root   | Targets the grid from a parent                     |
| `data-slot="radio-group-item"`                    | a button   | Targets the circle                                 |
| `data-slot="radio-group-indicator"`               | the dot    | Targets the animated indicator                     |
| `data-checked`, `data-unchecked`                  | item, dot  | Base UI writes them                                |
| `data-disabled`, `data-readonly`, `data-required` | root, item | Base UI writes them                                |
| `data-starting-style`, `data-ending-style`        | the dot    | Base UI writes them while the dot enters or leaves |
