# Radio

Choose one option, move the selection with arrow keys, and submit it with a form.

Use radios for a short list of choices where only one can be picked, like a shipping speed or a plan. The group keeps one value and gives the whole list a single tab stop.

```vue title="basic.vue"
<script setup lang="ts">
import { Field, FieldLabel } from "@ui-sdk/vue/field"
import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/vue/radio"

const speeds = [
  { value: "standard", label: "Standard" },
  { value: "express", label: "Express" },
  { value: "overnight", label: "Overnight" },
]
</script>

<template>
  <Field>
    <FieldLabel>Shipping speed</FieldLabel>
    <RadioGroup default-value="standard">
      <label v-for="speed in speeds" :key="speed.value" data-slot="label">
        <Radio :value="speed.value">
          <RadioIndicator />
        </Radio>
        {{ speed.label }}
      </label>
    </RadioGroup>
  </Field>
</template>
```

Choose Express, then use the arrow keys to move the selection between speeds.

## Installation

```bash
npm install @ui-sdk/vue @ui-sdk/core
```

## Composition

Use the following composition to build a `RadioGroup`:

```text
RadioGroup
├── Radio
│   └── RadioIndicator
└── Radio
    └── RadioIndicator
```

Give each radio a `value` and wrap it with its text in a label. The group's value is the checked radio's value, and the indicator shows while its radio is checked.

## Usage

Import the parts and put them together:

```vue
<script setup lang="ts">
import { Field, FieldLabel } from "@ui-sdk/vue/field"
import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/vue/radio"
</script>

<template>
  <Field>
    <FieldLabel>Shipping speed</FieldLabel>
    <RadioGroup default-value="standard">
      <label>
        <Radio value="standard">
          <RadioIndicator />
        </Radio>
        Standard
      </label>
      <label>
        <Radio value="express">
          <RadioIndicator />
        </Radio>
        Express
      </label>
    </RadioGroup>
  </Field>
</template>
```

The field's label names the group. Without a field, name it with `aria-label` or `aria-labelledby`. Without `default-value`, nothing starts checked and Tab enters on the first enabled radio.

## Description

Put each radio in its own `Field` with a `FieldLabel` and `FieldDescription` when an option needs more explanation. That field connects both to its radio, while the outer field still names the group.

```vue title="description.vue"
<script setup lang="ts">
import { Field, FieldDescription, FieldLabel } from "@ui-sdk/vue/field"
import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/vue/radio"

const frequencies = [
  { value: "instant", label: "Instantly", description: "An email right away." },
  {
    value: "daily",
    label: "Daily digest",
    description: "One email each morning.",
  },
  {
    value: "weekly",
    label: "Weekly digest",
    description: "A summary every Monday.",
  },
]
</script>

<template>
  <Field>
    <FieldLabel>Email notifications</FieldLabel>
    <RadioGroup default-value="daily">
      <Field v-for="frequency in frequencies" :key="frequency.value">
        <Radio :value="frequency.value">
          <RadioIndicator />
        </Radio>
        <div data-slot="stack">
          <FieldLabel>{{ frequency.label }}</FieldLabel>
          <FieldDescription>{{ frequency.description }}</FieldDescription>
        </div>
      </Field>
    </RadioGroup>
  </Field>
</template>
```

Choose Weekly digest by its label. Each radio is also described by the line under it.

## Choice Card

Wrap an option's content in its label to make the whole card clickable. `:has([data-checked])` lets the card reflect the radio inside it.

```vue title="choice-card.vue"
<script setup lang="ts">
import { Field, FieldLabel } from "@ui-sdk/vue/field"
import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/vue/radio"

const plans = [
  {
    value: "hobby",
    label: "Hobby",
    description: "Free for personal projects.",
  },
  {
    value: "pro",
    label: "Pro",
    description: "$20 per month for growing teams.",
  },
  {
    value: "enterprise",
    label: "Enterprise",
    description: "Custom pricing and support.",
  },
]
</script>

<template>
  <Field>
    <FieldLabel>Plan</FieldLabel>
    <RadioGroup default-value="hobby">
      <label v-for="plan in plans" :key="plan.value" data-slot="card">
        <span data-slot="stack">
          <span>{{ plan.label }}</span>
          <span data-slot="hint">{{ plan.description }}</span>
        </span>
        <Radio :value="plan.value">
          <RadioIndicator />
        </Radio>
      </label>
    </RadioGroup>
  </Field>
</template>
```

Click anywhere on the Pro card to choose it. Arrow keys move the selection between cards too.

## Fieldset and Legend

Use `FieldSet` and `FieldLegend` to describe a set of choices together. Connect the group to the legend with `aria-labelledby`, and to the description with `aria-describedby`.

```vue title="legend.vue"
<script setup lang="ts">
import { FieldDescription, FieldLegend, FieldSet } from "@ui-sdk/vue/field"
import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/vue/radio"
import { useId } from "vue"

const periods = [
  { value: "monthly", label: "Monthly", price: "$12 per month" },
  { value: "yearly", label: "Yearly", price: "$120 per year" },
]

const id = useId()
</script>

<template>
  <FieldSet>
    <FieldLegend :id="`${id}-legend`">Billing period</FieldLegend>
    <FieldDescription :id="`${id}-description`"
      >Yearly billing gets you two months free.</FieldDescription
    >
    <RadioGroup
      default-value="monthly"
      :aria-labelledby="`${id}-legend`"
      :aria-describedby="`${id}-description`"
    >
      <label v-for="period in periods" :key="period.value" data-slot="label">
        <Radio :value="period.value">
          <RadioIndicator />
        </Radio>
        {{ period.label }}
        <span data-slot="hint">({{ period.price }})</span>
      </label>
    </RadioGroup>
  </FieldSet>
</template>
```

Choose Yearly. The legend and the line under it describe both billing periods.

## Disabled

Set `disabled` on the group or on one radio. Disabled radios can't be focused or checked, and arrow keys skip them. A disabled fieldset disables the group too, and a disabled group submits nothing.

```vue title="disabled.vue"
<script setup lang="ts">
import { Field, FieldDescription, FieldLabel } from "@ui-sdk/vue/field"
import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/vue/radio"

const themes = [
  { value: "light", label: "Light" },
  { value: "dark", label: "Dark" },
  { value: "system", label: "System" },
]

const backups = [
  { value: "hourly", label: "Hourly", disabled: true },
  { value: "daily", label: "Daily", disabled: false },
  { value: "weekly", label: "Weekly", disabled: false },
]
</script>

<template>
  <div data-slot="stack">
    <Field>
      <FieldLabel>Theme</FieldLabel>
      <FieldDescription>Set by your organization.</FieldDescription>
      <RadioGroup default-value="light" disabled>
        <label v-for="theme in themes" :key="theme.value" data-slot="label">
          <Radio :value="theme.value">
            <RadioIndicator />
          </Radio>
          {{ theme.label }}
        </label>
      </RadioGroup>
    </Field>
    <Field>
      <FieldLabel>Backups</FieldLabel>
      <FieldDescription>Hourly backups need the Pro plan.</FieldDescription>
      <RadioGroup default-value="daily">
        <label v-for="backup in backups" :key="backup.value" data-slot="label">
          <Radio :value="backup.value" :disabled="backup.disabled">
            <RadioIndicator />
          </Radio>
          {{ backup.label }}
        </label>
      </RadioGroup>
    </Field>
  </div>
</template>
```

The Theme group is disabled. In Backups, Hourly is disabled, so the arrow keys move between Daily and Weekly.

## Read-Only

Set `read-only` on the group to keep its value while still letting people focus and read the options. Arrow keys move focus without checking anything, and the value still submits with a form.

```vue title="read-only.vue"
<script setup lang="ts">
import { Field, FieldDescription, FieldLabel } from "@ui-sdk/vue/field"
import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/vue/radio"

const plans = [
  { value: "free", label: "Free" },
  { value: "pro", label: "Pro" },
  { value: "team", label: "Team" },
]
</script>

<template>
  <Field>
    <FieldLabel>Your plan</FieldLabel>
    <FieldDescription>Ask an admin to change plans.</FieldDescription>
    <RadioGroup default-value="pro" read-only>
      <label v-for="plan in plans" :key="plan.value" data-slot="label">
        <Radio :value="plan.value">
          <RadioIndicator />
        </Radio>
        {{ plan.label }}
      </label>
    </RadioGroup>
  </Field>
</template>
```

Click Team, or focus Pro and press the Down arrow. Pro stays checked.

## Controlled

Use `v-model:value` to own the value, and `@value-change` for the reason. Call `details.cancel()` in `@value-change` to keep the current value. A [controller](#controller) from `createRadioGroupController()` checks a radio from anywhere, like the Choose Team button below. Create one controller per mounted group.

```vue title="controlled.vue"
<script setup lang="ts">
import { Field, FieldLabel } from "@ui-sdk/vue/field"
import {
  createRadioGroupController,
  Radio,
  RadioGroup,
  RadioIndicator,
} from "@ui-sdk/vue/radio"
import { ref } from "vue"
import { useLog } from "./log"

const plans = [
  { value: "starter", label: "Starter" },
  { value: "pro", label: "Pro" },
  { value: "team", label: "Team" },
]

const log = useLog()
const planGroup = createRadioGroupController()
const value = ref<string | null>("pro")

function onValueChange(next: string | null, details: { reason: string }) {
  log(
    `${plans.find((plan) => plan.value === next)?.label ?? "None"} (${details.reason})`
  )
}
</script>

<template>
  <div data-slot="stack">
    <div data-slot="controls">
      <button type="button" @click="planGroup.setValue('team')">
        Choose Team
      </button>
    </div>
    <Field>
      <FieldLabel>Plan</FieldLabel>
      <RadioGroup
        v-model:value="value"
        :controller="planGroup"
        @value-change="onValueChange"
      >
        <label v-for="plan in plans" :key="plan.value" data-slot="label">
          <Radio :value="plan.value">
            <RadioIndicator />
          </Radio>
          {{ plan.label }}
        </label>
      </RadioGroup>
    </Field>
  </div>
</template>
```

Choose Starter, then press Choose Team. The console logs each change and its reason.

See [State and Events](/docs/vue/state-and-events) for controlled state, cancellation and debugging.

## Form

Each radio renders a button with the `radio` role and a hidden native radio, so the group takes part in forms. Set `name` to submit the checked value; the group submits nothing without a name or a checked radio. `required` blocks submission until there's a choice, and a reset returns to `default-value`.

Set `form` to a form's id to join it from elsewhere on the page, as this group does.

```vue title="form.vue"
<script setup lang="ts">
import { Field, FieldLabel } from "@ui-sdk/vue/field"
import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/vue/radio"
import { useId } from "vue"
import { useLog } from "./log"

const speeds = [
  { value: "standard", label: "Standard", detail: "Free, 5 to 7 days" },
  { value: "express", label: "Express", detail: "$9, 2 days" },
  { value: "overnight", label: "Overnight", detail: "$24, next day" },
]

const log = useLog()
const id = useId()

function submit(event: Event) {
  log(
    `Submitted shipping=${new FormData(event.currentTarget as HTMLFormElement).get("shipping")}`
  )
}
</script>

<template>
  <div data-slot="stack">
    <Field>
      <FieldLabel>Shipping speed</FieldLabel>
      <RadioGroup
        name="shipping"
        :form="`${id}-checkout`"
        required
        default-value="standard"
      >
        <label v-for="speed in speeds" :key="speed.value" data-slot="label">
          <Radio :value="speed.value">
            <RadioIndicator />
          </Radio>
          {{ speed.label }}
          <span data-slot="hint">({{ speed.detail }})</span>
        </label>
      </RadioGroup>
    </Field>
    <form :id="`${id}-checkout`" data-slot="row" @submit.prevent="submit">
      <button type="submit" data-variant="solid">Continue</button>
      <button type="reset">Reset</button>
    </form>
  </div>
</template>
```

Choose Express and press Continue. The console logs the submitted value, and Reset returns the choice to Standard.

## Validation

Put a required group in a [Field](/docs/vue/components/field) with a `FieldError`. Submitting without a choice shows the error under the group and moves focus to it. The [Forms guide](/docs/vue/forms) covers the shared field behavior.

```vue title="invalid.vue"
<script setup lang="ts">
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@ui-sdk/vue/field"
import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/vue/radio"
import { useLog } from "./log"

const channels = [
  { value: "email", label: "Email only" },
  { value: "sms", label: "SMS only" },
  { value: "both", label: "Email and SMS" },
]

const log = useLog()

function submit(event: Event) {
  log(
    `Saved updates=${new FormData(event.currentTarget as HTMLFormElement).get("updates")}`
  )
}
</script>

<template>
  <form data-slot="stack" @submit.prevent="submit">
    <Field>
      <FieldLabel>Order updates</FieldLabel>
      <FieldDescription
        >Choose how we tell you about your orders.</FieldDescription
      >
      <RadioGroup name="updates" required>
        <label
          v-for="channel in channels"
          :key="channel.value"
          data-slot="label"
        >
          <Radio :value="channel.value">
            <RadioIndicator />
          </Radio>
          {{ channel.label }}
        </label>
      </RadioGroup>
      <FieldError>Choose how to get order updates.</FieldError>
    </Field>
    <div data-slot="row">
      <button type="submit" data-variant="solid">Save</button>
    </div>
  </form>
</template>
```

Press Save without a choice to see the error. Choose SMS only and save again; the console logs the saved value.

## Animation

The indicator has `data-starting-style` as it appears and `data-ending-style` while it leaves, and stays mounted until its exit finishes. Transition from those states:

```css
[data-slot="radio-indicator"] {
  transition:
    opacity 0.15s,
    scale 0.15s;

  &:is([data-starting-style], [data-ending-style]) {
    opacity: 0;
    scale: 0.5;
  }
}
```

`keep-mounted` keeps the indicator in the page while unchecked, with `data-unchecked`. Here it lets a hovered option preview its dot.

```vue title="indicator.vue"
<script setup lang="ts">
import { Field, FieldLabel } from "@ui-sdk/vue/field"
import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/vue/radio"

const themes = [
  { value: "light", label: "Light" },
  { value: "dark", label: "Dark" },
  { value: "system", label: "System" },
]
</script>

<template>
  <Field>
    <FieldLabel>Theme</FieldLabel>
    <RadioGroup default-value="system">
      <label v-for="theme in themes" :key="theme.value" data-slot="label">
        <Radio :value="theme.value">
          <RadioIndicator keep-mounted />
        </Radio>
        {{ theme.label }}
      </label>
    </RadioGroup>
  </Field>
</template>
```

Hover Light to preview its dot, then choose it. The dot scales in on Light and out of System.

## Loop

Arrow keys wrap from the last radio to the first. Set `loop` to `false` to stop at the ends.

```vue
<RadioGroup default-value="low" :loop="false">
  ...
</RadioGroup>
```

## Custom Rendering

The group, radio and indicator take a `render` slot, which receives the props to bind and the part's state. Keep the part's props on the interactive element so its role, label and keyboard behavior stay connected. See [Parts and Composition](/docs/vue/parts-and-composition) for the rendering contract.

```vue title="render.vue"
<script setup lang="ts">
import { CheckIcon } from "@lucide/vue"
import { Field, FieldLabel } from "@ui-sdk/vue/field"
import { Radio, RadioGroup } from "@ui-sdk/vue/radio"
import { useId } from "vue"

const options = [
  {
    value: "all",
    label: "Everything",
    description: "Every message and update.",
  },
  {
    value: "mentions",
    label: "Mentions",
    description: "Only when someone mentions you.",
  },
  { value: "none", label: "Nothing", description: "Turn notifications off." },
]

const id = useId()
</script>

<template>
  <Field>
    <FieldLabel>Notify me about</FieldLabel>
    <RadioGroup default-value="mentions">
      <Radio
        v-for="option in options"
        :key="option.value"
        :value="option.value"
        :aria-labelledby="`${id}-${option.value}-label`"
        :aria-describedby="`${id}-${option.value}-description`"
      >
        <template #render="{ props, state }">
          <button v-bind="props">
            <span data-slot="stack">
              <span :id="`${id}-${option.value}-label`">{{
                option.label
              }}</span>
              <span
                :id="`${id}-${option.value}-description`"
                data-slot="hint"
                >{{ option.description }}</span
              >
            </span>
            <CheckIcon
              aria-hidden="true"
              :data-checked="state.checked ? '' : undefined"
            />
          </button>
        </template>
      </Radio>
    </RadioGroup>
  </Field>
</template>
```

Choose Nothing. Each radio renders a full-width button that shows a check from its state.

## Styling

Use `data-checked` and `data-unchecked` for the checked state, and `data-disabled`, `data-readonly` and `data-required` for the rest. The indicator gets the same state attributes as its radio, and the group gets `data-disabled`, `data-readonly` and `data-required`.

Each part has a `data-slot`, such as `radio`, and its state attributes are listed under [Data Attributes](#data-attributes).

## Data Attributes

### RadioGroup
`[data-slot="radio-group"]`

- `data-readonly`: Present when the group is read-only.
- `data-required`: Present when the group is required.
- `data-disabled`: Present when disabled, by its own prop, the group or a `<fieldset>`.

### Radio
`[data-slot="radio"]`

- `data-checked`: Present on the checked radio.
- `data-readonly`: Present when the group is read-only.
- `data-required`: Present when the group is required.
- `data-unchecked`: Present on radios that aren't checked.
- `data-disabled`: Present when disabled, by its own prop, the group or a `<fieldset>`.

### RadioIndicator
`[data-slot="radio-indicator"]`

- `data-checked`: Present on the checked radio.
- `data-readonly`: Present when the group is read-only.
- `data-required`: Present when the group is required.
- `data-unchecked`: Present on radios that aren't checked.
- `data-disabled`: Present when disabled, by its own prop, the group or a `<fieldset>`.
- `data-starting-style`: Present for the indicator's first frame. Style the state you animate from.
- `data-ending-style`: Present while the indicator animates out. Style the state you animate to.

## API Reference

Props are the same as below. `onValueChange` is `v-model:value`, or `@value-change` for the reason.

### RadioGroup

- `value?` (`T | null`): The checked radio's value. Pass it to control the group. `null` checks nothing.
- `defaultValue?` (`T | null`): The radio checked at first, when uncontrolled. A form reset returns to it.
- `@value-change` (`((value: T | null, details: ChangeDetails<RadioGroupValueChangeReason>) => void)`): Runs when a user or a form reset wants to change the value. `details.reason` says why, and `details.cancel()` stops it.
- `disabled?` (`boolean`): Disables every radio like native ones: they can't be focused or changed, and the value isn't submitted.
- `readOnly?` (`boolean`): Keeps the items focusable and the value submitted, but stops changes.
- `required?` (`boolean`): Blocks form submission until an item is checked, with the browser's own message.
- `name?` (`string`): The name the value submits under. Without one, the group submits nothing.
- `form?` (`string`): The id of a form to belong to, when the group isn't inside that form.
- `loop?` (`boolean`): Whether arrow keys wrap from the last item to the first.
- `controller?` (`{ getValue: () => T | null; setValue: (value: T | null, details?: Details) => boolean; focus: () => void; }`): Checks radios from anywhere. Create one with `createRadioGroupController()`.
- `debug?` (`boolean`): Logs every state change with its reason to the console, in development builds.

### Radio

- `value` (`string`): What the group's value becomes when this item is checked.
- `disabled?` (`boolean`): Skips the item, like a disabled native radio: arrow keys pass it and it can't be focused or checked.

### RadioIndicator

- `keepMounted?` (`boolean`): Whether the indicator stays in the page while its item is unchecked.

### State

What render functions, state-based class names and slots receive.

- `value` (`string | null`): The checked item's value.
- `disabled` (`boolean`): Whether the group is disabled, by itself or by a `<fieldset>`.
- `readOnly` (`boolean`): Whether the group is read-only: arrow keys still move focus, but nothing changes the checked item.
- `required` (`boolean`): Whether a form won't submit until an item is checked.

- `checked` (`boolean`): Whether this item is checked.
- `disabled` (`boolean`): Whether this item is disabled, by itself, its group or a `<fieldset>`.
- `readOnly` (`boolean`): Whether the group is read-only.
- `required` (`boolean`): Whether the group is required.

- `checked` (`boolean`): Whether this item is checked.
- `disabled` (`boolean`): Whether this item is disabled, by itself, its group or a `<fieldset>`.
- `readOnly` (`boolean`): Whether the group is read-only.
- `required` (`boolean`): Whether the group is required.

### Reasons

`details.reason` says why the checked value changed.

- `item-press` (`MouseEvent | KeyboardEvent`): A member of a collection was chosen: a menu item, a tab, a radio or a checkbox item.
- `list-navigation` (`KeyboardEvent`): Arrow, Home or End keys moved the selection, typing picked an item, or Ctrl or ⌘ with A selected every item of a listbox.
- `reset` (`none`): A form reset.
- `restore` (`none`): The browser restored state, like after going back, or a saved value was read back from storage.
- `imperative` (`none`): A controller or `api` call, or a property set on a web component.

### Controller

Create one with `createRadioGroupController()` and pass it as `controller`.

Every write method takes an optional `{ reason, event }`, runs the change callback like a press, and returns `false` when the change is canceled.

- `getValue()` (`() => T | null`): The checked radio's value.
- `setValue(value, details?)` (`(value: T | null, details?: RadioGroupChangeOptions | undefined) => boolean`): Checks a radio, as a user would. Disabled radios and read-only groups ignore it.
- `focus()` (`() => void`): Moves focus to the checked radio, or the first one.

### Events

Events get the new value first, then the details. `v-model` only updates when the change event wasn't canceled.

| Binding         | On           | Receives                                                          |
| --------------- | ------------ | ----------------------------------------------------------------- |
| `v-model:value` | `RadioGroup` | The checked value                                                 |
| `@value-change` | `RadioGroup` | `(value, details)`: the checked value, and the [reason](#reasons) |

## Accessibility

Radio groups follow the [WAI-ARIA radio group pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/), and behave like native radios that share a name. `Radio` renders a button with the `radio` role, and a hidden native radio that takes part in forms.

### Keyboard

| Key        | Does                                                                    |
| ---------- | ----------------------------------------------------------------------- |
| Tab        | Moves into the group, onto the checked radio or the first enabled one   |
| Arrow keys | Move to the next or previous radio and check it, skipping disabled ones |
| Space      | Checks the focused radio                                                |
| Enter      | Submits its form                                                        |

### Tested Behavior

Every line below is an automated test.

**Selection**

- Nothing is checked at first without a default value.
- Clicking an item checks it and unchecks the others.
- Clicking an item's label checks it.
- Clicking a disabled item does nothing.
- A change can be cancelled.

**Keyboard**

- Tab lands on the first item when nothing is checked.
- Tab lands on the checked item.
- Tab leaves the group after one stop.
- Space checks the focused item.
- ArrowDown and ArrowRight move to the next item and check it.
- ArrowUp and ArrowLeft move to the previous item and check it.
- Arrow keys skip disabled items.
- Arrow keys wrap at both ends.
- Enter never checks an item.
- Keys pressed in quick succession are never lost.
- Enter on an item submits the form.

**Read-only and disabled**

- A read-only group moves focus with arrow keys but checks nothing.
- A disabled group can't be focused or changed.
- Clicking a radio in a disabled group doesn't focus it.
- Clicking the label of a radio in a disabled group neither focuses nor checks it.

**Animation**

- The indicator animates in from its starting style.

Runs in React, Vue and web components.

In a field:

**With every radio enabled**

- Each radio is named by the label of its own field.
- The field around the group still names the group.
- Clicking a radio's label checks it and unchecks the others.
- The group keeps one tab stop.
- Arrow keys move between the radios and check them.

**A disabled radio**

- A disabled field around the first radio moves the tab stop to the next.
- Disabling the first radio after it renders moves the tab stop to the next.
- Enabling the first radio again gives it back the tab stop.

Runs in React, Vue and web components.

In right-to-left pages:

- ArrowLeft moves to the next radio and ArrowRight to the previous one.
- ArrowDown and ArrowUp keep their direction.

Runs in React, Vue and web components.
