# Radio group

Lets people choose exactly one option from a short list that stays visible.

- **Import:** `import { RadioGroup, RadioGroupItem } from "@booleanpress/ui/radio-group"`
- **Radix Radio Group:** <https://www.radix-ui.com/primitives/docs/components/radio-group>
- **APG Radio Group:** <https://www.w3.org/WAI/ARIA/apg/patterns/radio/>
- **Page:** <https://ui.booleanpress.com/components/radio-group> · @booleanpress/ui 0.1.0

## Usage

Name the group with `aria-label`, or with a visible legend and `aria-labelledby`, and name every radio with a `Label` whose `htmlFor` is the radio's `id`. Clicking the label chooses the radio.

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

export function SendingMode() {
  return (
    <RadioGroup defaultValue="queue" aria-label="Sending mode">
      <div className="flex items-center gap-2">
        <RadioGroupItem id="mode-instant" value="instant" />
        <Label htmlFor="mode-instant">Send at once</Label>
      </div>
      <div className="flex items-center gap-2">
        <RadioGroupItem id="mode-queue" value="queue" />
        <Label htmlFor="mode-queue">Send through the queue</Label>
      </div>
    </RadioGroup>
  )
}
```

It is uncontrolled with `defaultValue`, or controlled with `value` and `onValueChange`; the value is a string. The group lays its radios out in a column with a 12 px gap. For a row, pass `className="flex gap-6"` (and `orientation="horizontal"`, so the arrow keys match what people see). Once one radio is chosen, the group cannot go back to none by a click; use a `Select` or a `Checkbox` when "nothing" is a valid answer. For two options that switch something on or off, use `Switch`.

## Examples

### Basic

Three options, one chosen, each named by its label.

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

export default function RadioGroupBasic() {
  return (
    <RadioGroup defaultValue="queue" aria-label="Sending mode">
      <div className="flex items-center gap-2">
        <RadioGroupItem id="mode-instant" value="instant" />
        <Label htmlFor="mode-instant">Send at once</Label>
      </div>
      <div className="flex items-center gap-2">
        <RadioGroupItem id="mode-queue" value="queue" />
        <Label htmlFor="mode-queue">Send through the queue</Label>
      </div>
      <div className="flex items-center gap-2">
        <RadioGroupItem id="mode-off" value="off" />
        <Label htmlFor="mode-off">Do not send</Label>
      </div>
    </RadioGroup>
  )
}
```

### Controlled

`value` and `onValueChange` keep the choice in your state, here shown beneath the group.

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

const PRIORITIES = ["low", "normal", "high", "urgent"]

export default function RadioGroupControlled() {
  const [priority, setPriority] = useState("normal")

  return (
    <div className="flex flex-col gap-3">
      <RadioGroup value={priority} onValueChange={setPriority} aria-label="Ticket priority">
        {PRIORITIES.map((value) => (
          <div key={value} className="flex items-center gap-2">
            <RadioGroupItem id={`priority-${value}`} value={value} />
            <Label htmlFor={`priority-${value}`} className="capitalize">
              {value}
            </Label>
          </div>
        ))}
      </RadioGroup>
      <p className="text-sm text-muted-foreground">Priority: {priority}</p>
    </div>
  )
}
```

### Horizontal

A row of short options; `orientation="horizontal"` tells the arrow keys and assistive technology it is a row.

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

export default function RadioGroupHorizontal() {
  return (
    <RadioGroup defaultValue="30" orientation="horizontal" aria-label="Keep logs for" className="flex gap-6">
      {["7", "30", "90"].map((days) => (
        <div key={days} className="flex items-center gap-2">
          <RadioGroupItem id={`keep-${days}`} value={days} />
          <Label htmlFor={`keep-${days}`}>{days} days</Label>
        </div>
      ))}
    </RadioGroup>
  )
}
```

### Disabled

`disabled` on the group stops every radio; on one `RadioGroupItem` it stops that radio and the arrow keys skip it.

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

export default function RadioGroupDisabled() {
  return (
    <div className="flex flex-col gap-6">
      <RadioGroup defaultValue="smtp" disabled aria-label="Transport, whole group disabled">
        <div className="flex items-center gap-2">
          <RadioGroupItem id="transport-smtp" value="smtp" />
          <Label htmlFor="transport-smtp">SMTP</Label>
        </div>
        <div className="flex items-center gap-2">
          <RadioGroupItem id="transport-api" value="api" />
          <Label htmlFor="transport-api">HTTP API</Label>
        </div>
      </RadioGroup>
      <RadioGroup defaultValue="free" aria-label="Plan, one option disabled">
        <div className="flex items-center gap-2">
          <RadioGroupItem id="plan-free" value="free" />
          <Label htmlFor="plan-free">Free</Label>
        </div>
        <div className="flex items-center gap-2">
          <RadioGroupItem id="plan-pro" value="pro" disabled />
          <Label htmlFor="plan-pro">Pro (needs a licence)</Label>
        </div>
      </RadioGroup>
    </div>
  )
}
```

### Invalid

`aria-invalid` on the group and its radios draws the error border; `aria-describedby` reads the message with the group's name.

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

export default function RadioGroupInvalid() {
  return (
    <div className="flex flex-col gap-3">
      <RadioGroup required aria-label="Reply handling" aria-invalid aria-describedby="reply-error">
        <div className="flex items-center gap-2">
          <RadioGroupItem id="reply-assign" value="assign" aria-invalid />
          <Label htmlFor="reply-assign">Assign to the sender</Label>
        </div>
        <div className="flex items-center gap-2">
          <RadioGroupItem id="reply-open" value="open" aria-invalid />
          <Label htmlFor="reply-open">Leave unassigned</Label>
        </div>
      </RadioGroup>
      <p id="reply-error" className="text-sm text-destructive">
        Choose how replies are handled.
      </p>
    </div>
  )
}
```

## Accessibility

**Semantics.** The group is `role="radiogroup"`; each item is a `button` with `role="radio"` and `aria-checked`. Inside a form, a hidden native input per radio carries the value, so it is submitted with the form.

**Labels.** Name the group (`aria-label` or `aria-labelledby`) and every radio (a `Label` with `htmlFor`). Put an error message in `aria-describedby` on the group, and set `aria-invalid` on the group and on each radio.

**Focus.** The group is one tab stop: Tab goes to the chosen radio, or to the first when none is chosen, and the other radios are skipped. The arrow keys move focus and choose together. The focus ring shows on keyboard focus, not on click.

**Known limits.**

- The radio is 16 px. Keep its label beside it, so the label adds to the target and the pair meets WCAG 2.5.8's 24 px.
- `aria-invalid` is not inherited: put it on the group (so the group reads as invalid) and on each radio (for the red border).

### Keyboard

| Key | Behaviour |
| --- | --- |
| Tab | Moves focus into the group, to the chosen radio, or to the first radio when none is chosen. A second Tab leaves the group. |
| ArrowDown + ArrowRight | Moves focus to the next radio and chooses it. From the last radio, it wraps to the first. In right-to-left, ArrowLeft moves forward instead of ArrowRight. |
| ArrowUp + ArrowLeft | Moves focus to the previous radio and chooses it. From the first radio, it wraps to the last. In right-to-left, ArrowRight moves back instead of ArrowLeft. |
| Space | Chooses the focused radio, if it is not already chosen. |

## API

### RadioGroup

Renders Radix RadioGroup.Root and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  | Render the child element instead, with this part's behaviour and classes merged onto it. |
| `defaultValue` | `string` |  | The value it starts with, when it controls itself. |
| `dir` | `"ltr" \| "rtl"` |  | Reading direction for the arrow keys. Defaults to the provider's. |
| `disabled` | `boolean` |  | Stops every radio in the group. |
| `form` | `string` |  |  |
| `loop` | `boolean` |  | Whether the arrow keys wrap from the last radio to the first. Defaults to `true`. |
| `name` | `string` |  | The field name, for the value the form submits. |
| `onValueChange` | `((value: string) => void)` |  | Called with the new value when a radio is chosen. |
| `orientation` | `"horizontal" \| "vertical"` |  | `horizontal` or `vertical`, for the arrow keys and `aria-orientation`. It does not change the layout: set that with `className`. |
| `required` | `boolean` |  | The form cannot be submitted until a radio is chosen. |
| `value` | `string \| null` |  | The chosen value, when you control it. Pair it with `onValueChange`. |

### RadioGroupItem

Renders Radix RadioGroup.Item and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  | Render the child element instead, with this part's behaviour and classes merged onto it. |
| `checked` | `boolean` |  |  |
| `required` | `boolean` |  | The form cannot be submitted until this radio is chosen. |
| `value` | `string \| null` |  | The value this radio chooses. |

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="radio-group"` (RadioGroup), `data-slot="radio-group-item"` (RadioGroupItem).

## Theming

Each radio's edge is `--control`, at least 3:1 against the surface (WCAG 1.4.11); the chosen dot is `--primary`; focus is `--ring` at 50 %; invalid is `--destructive`.

| Token | Used for |
| --- | --- |
| `--control` | border |
| `--destructive` | border, focus ring |
| `--input` | border, background |
| `--primary` | text, fill |
| `--ring` | border, focus ring |
