# 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.

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

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

<Field>
  <FieldLabel>Shipping speed</FieldLabel>
  <RadioGroup defaultValue="standard">
    {#each speeds as speed (speed.value)}
      <label data-slot="label">
        <Radio value={speed.value}>
          <RadioIndicator />
        </Radio>
        {speed.label}
      </label>
    {/each}
  </RadioGroup>
</Field>
```

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

## Installation

```bash
npm install @ui-sdk/svelte @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:

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

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

The field's label names the group. Without a field, name it with `aria-label` or `aria-labelledby`. Without `defaultValue`, 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.

```svelte title="description.svelte"
<script lang="ts">
  import { Field, FieldDescription, FieldLabel } from "@ui-sdk/svelte/field"
  import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/svelte/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>

<Field>
  <FieldLabel>Email notifications</FieldLabel>
  <RadioGroup defaultValue="daily">
    {#each frequencies as frequency (frequency.value)}
      <Field>
        <Radio value={frequency.value}>
          <RadioIndicator />
        </Radio>
        <div data-slot="stack">
          <FieldLabel>{frequency.label}</FieldLabel>
          <FieldDescription>{frequency.description}</FieldDescription>
        </div>
      </Field>
    {/each}
  </RadioGroup>
</Field>
```

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.

```svelte title="choice-card.svelte"
<script lang="ts">
  import { Field, FieldLabel } from "@ui-sdk/svelte/field"
  import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/svelte/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>

<Field>
  <FieldLabel>Plan</FieldLabel>
  <RadioGroup defaultValue="hobby">
    {#each plans as plan (plan.value)}
      <label 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>
    {/each}
  </RadioGroup>
</Field>
```

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`.

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

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

  const id = $props.id()
</script>

<FieldSet>
  <FieldLegend id={`${id}-legend`}>Billing period</FieldLegend>
  <FieldDescription id={`${id}-description`}
    >Yearly billing gets you two months free.</FieldDescription
  >
  <RadioGroup
    defaultValue="monthly"
    aria-labelledby={`${id}-legend`}
    aria-describedby={`${id}-description`}
  >
    {#each periods as period (period.value)}
      <label data-slot="label">
        <Radio value={period.value}>
          <RadioIndicator />
        </Radio>
        {period.label}
        <span data-slot="hint">({period.price})</span>
      </label>
    {/each}
  </RadioGroup>
</FieldSet>
```

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.

```svelte title="disabled.svelte"
<script lang="ts">
  import { Field, FieldDescription, FieldLabel } from "@ui-sdk/svelte/field"
  import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/svelte/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>

<div data-slot="stack">
  <Field>
    <FieldLabel>Theme</FieldLabel>
    <FieldDescription>Set by your organization.</FieldDescription>
    <RadioGroup defaultValue="light" disabled>
      {#each themes as theme (theme.value)}
        <label data-slot="label">
          <Radio value={theme.value}>
            <RadioIndicator />
          </Radio>
          {theme.label}
        </label>
      {/each}
    </RadioGroup>
  </Field>
  <Field>
    <FieldLabel>Backups</FieldLabel>
    <FieldDescription>Hourly backups need the Pro plan.</FieldDescription>
    <RadioGroup defaultValue="daily">
      {#each backups as backup (backup.value)}
        <label data-slot="label">
          <Radio value={backup.value} disabled={backup.disabled}>
            <RadioIndicator />
          </Radio>
          {backup.label}
        </label>
      {/each}
    </RadioGroup>
  </Field>
</div>
```

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

## Read-Only

Set `readOnly` 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.

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

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

<Field>
  <FieldLabel>Your plan</FieldLabel>
  <FieldDescription>Ask an admin to change plans.</FieldDescription>
  <RadioGroup defaultValue="pro" readOnly>
    {#each plans as plan (plan.value)}
      <label data-slot="label">
        <Radio value={plan.value}>
          <RadioIndicator />
        </Radio>
        {plan.label}
      </label>
    {/each}
  </RadioGroup>
</Field>
```

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

## Controlled

Use `bind:value` to own the value, and `onValueChange` for the reason. Call `details.cancel()` in the callback 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.

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

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

  const log = useLog()
  const planGroup = createRadioGroupController()
  let value = $state<string | null>("pro")
</script>

<div data-slot="stack">
  <div data-slot="controls">
    <button type="button" onclick={() => planGroup.setValue("team")}
      >Choose Team</button
    >
  </div>
  <Field>
    <FieldLabel>Plan</FieldLabel>
    <RadioGroup
      bind:value
      onValueChange={(next, details) =>
        log(
          `${plans.find((plan) => plan.value === next)?.label ?? "None"} (${details.reason})`
        )}
      controller={planGroup}
    >
      {#each plans as plan (plan.value)}
        <label data-slot="label">
          <Radio value={plan.value}>
            <RadioIndicator />
          </Radio>
          {plan.label}
        </label>
      {/each}
    </RadioGroup>
  </Field>
</div>
```

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

See [State and Events](/docs/svelte/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 `defaultValue`.

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

```svelte title="form.svelte"
<script lang="ts">
  import { Field, FieldLabel } from "@ui-sdk/svelte/field"
  import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/svelte/radio"
  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 = $props.id()

  function onSubmit(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
    event.preventDefault()
    log(
      `Submitted shipping=${new FormData(event.currentTarget).get("shipping")}`
    )
  }
</script>

<div data-slot="stack">
  <Field>
    <FieldLabel>Shipping speed</FieldLabel>
    <RadioGroup
      name="shipping"
      form={`${id}-checkout`}
      required
      defaultValue="standard"
    >
      {#each speeds as speed (speed.value)}
        <label data-slot="label">
          <Radio value={speed.value}>
            <RadioIndicator />
          </Radio>
          {speed.label}
          <span data-slot="hint">({speed.detail})</span>
        </label>
      {/each}
    </RadioGroup>
  </Field>
  <form data-slot="row" id={`${id}-checkout`} onsubmit={onSubmit}>
    <button type="submit" data-variant="solid">Continue</button>
    <button type="reset">Reset</button>
  </form>
</div>
```

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/svelte/components/field) with a `FieldError`. Submitting without a choice shows the error under the group and moves focus to it. The [Forms guide](/docs/svelte/forms) covers the shared field behavior.

```svelte title="invalid.svelte"
<script lang="ts">
  import {
    Field,
    FieldDescription,
    FieldError,
    FieldLabel,
  } from "@ui-sdk/svelte/field"
  import { Radio, RadioGroup, RadioIndicator } from "@ui-sdk/svelte/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 onSubmit(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
    event.preventDefault()
    log(`Saved updates=${new FormData(event.currentTarget).get("updates")}`)
  }
</script>

<form data-slot="stack" onsubmit={onSubmit}>
  <Field>
    <FieldLabel>Order updates</FieldLabel>
    <FieldDescription
      >Choose how we tell you about your orders.</FieldDescription
    >
    <RadioGroup name="updates" required>
      {#each channels as channel (channel.value)}
        <label data-slot="label">
          <Radio value={channel.value}>
            <RadioIndicator />
          </Radio>
          {channel.label}
        </label>
      {/each}
    </RadioGroup>
    <FieldError>Choose how to get order updates.</FieldError>
  </Field>
  <div data-slot="row">
    <button type="submit" data-variant="solid">Save</button>
  </div>
</form>
```

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;
  }
}
```

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

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

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

<Field>
  <FieldLabel>Theme</FieldLabel>
  <RadioGroup defaultValue="system">
    {#each themes as theme (theme.value)}
      <label data-slot="label">
        <Radio value={theme.value}>
          <RadioIndicator keepMounted />
        </Radio>
        {theme.label}
      </label>
    {/each}
  </RadioGroup>
</Field>
```

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.

```svelte
<RadioGroup defaultValue="low" loop={false}>...</RadioGroup>
```

## Custom Rendering

The group, radio and indicator take a `render` snippet, which receives the props to spread 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/svelte/parts-and-composition) for the rendering contract.

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

  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 = $props.id()
</script>

<Field>
  <FieldLabel>Notify me about</FieldLabel>
  <RadioGroup defaultValue="mentions">
    {#each options as option (option.value)}
      <Radio
        value={option.value}
        aria-labelledby={`${id}-${option.value}-label`}
        aria-describedby={`${id}-${option.value}-description`}
      >
        {#snippet render({ props, state })}
          <button {...props}>
            <span data-slot="stack">
              <span id={`${id}-${option.value}-label`}>{option.label}</span>
              <span data-slot="hint" id={`${id}-${option.value}-description`}
                >{option.description}</span
              >
            </span>
            <CheckIcon
              aria-hidden="true"
              data-checked={state.checked ? "" : undefined}
            />
          </button>
        {/snippet}
      </Radio>
    {/each}
  </RadioGroup>
</Field>
```

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 and callbacks are the same as below, as Svelte props. `value` can be bound with `bind:value`.

### 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.
- `onValueChange?` (`((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

Callbacks get the new value first, then the details. A bound `value` only updates when `onValueChange` wasn't canceled.

| Prop            | On           | Receives                                                          |
| --------------- | ------------ | ----------------------------------------------------------------- |
| `bind:value`    | `RadioGroup` | The checked value                                                 |
| `onValueChange` | `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.
