Skip to content

Radio

A group of mutually exclusive options, laid out so the user sees them all at once.

View source View as Markdown

Radio is a group of mutually exclusive options that takes part in forms, and behaves as one control with one label. Useful when the set is small enough to show in full; once the list needs scrolling, reach for Select.

App.vue
<template>
  <Radio.Group
    v-model="plan"
    aria-label="Plan"
    class="flex flex-col gap-3"
    :options="{ name: 'plan' }"
  >
    <div
      v-for="option in plans"
      :key="option.value"
      class="flex items-start gap-3"
    >
      <Radio.Root
        :id="`plan-${option.value}`"
        :class="box"
        :disabled="option.disabled"
        :value="option.value"
      >
        <Radio.Indicator :class="dot" />
      </Radio.Root>

      <Label
        :class="label"
        :data-disabled="option.disabled ? 'true' : undefined"
        :for="`plan-${option.value}`"
      >
        {{ option.label }}
      </Label>
    </div>
  </Radio.Group>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Label, Radio } from '@maas/mirror/vue'
const box = [
  'flex size-5 items-center justify-center border-2 border-surface',
  'rounded-component-round text-accent-on-solid',
  'outline-4 outline-transparent transition-all duration-100 ease-linear focus-visible:focus-ring',
  'active:border-primary-subtle',
  'data-[state=checked]:bg-accent-solid',
  'data-[state=checked]:border-[transparent]',
  'data-[state=checked]:active:bg-accent-solid-active',
  'data-[disabled=true]:border-disabled-subtle',
  'data-[disabled=true]:data-[state=checked]:bg-disabled-solid',
  'data-[disabled=true]:text-disabled-on-solid',
].join(' ')

const dot = 'rounded-component-round block size-2 bg-current'

const label = [
  'type-component-xs text-surface translate-y-[0.1rem] [--mirror-label-cursor:pointer]',
  'data-[disabled=true]:text-disabled-solid',
].join(' ')

const plans = [
  { value: 'hobby', label: 'Hobby', disabled: false },
  { value: 'team', label: 'Team', disabled: false },
  { value: 'enterprise', label: 'Enterprise', disabled: true },
]

const plan = ref('team')
</script>

Usage guidelines

  • The ARIA radio group pattern keeps the whole group as one tab stop, so focus and selection travel together and the arrow keys commit a choice as they go. If the user should be able to browse before choosing, reach for something else.
  • Both arrow pairs move through the group by default, which is what the ARIA pattern asks for. Set orientation to 'horizontal' or 'vertical' only where one axis belongs to something else, a slider or a scrolling panel beside the group.
  • Give every per-item Label its own id, or inside a Field they share the field’s label ID and all register as its label.

Anatomy

Assemble the group, one Radio.Root per option.

NameRequiredDescription
Radio.Group true Renders a div with role="radiogroup", plus one hidden input per item.
Radio.Root true Renders a button with role="radio". One per option.
Radio.IndicatorRenders a span. Only while the item is selected.
<script setup lang="ts">
import { Field, Label, Radio } from '@maas/mirror/vue'
</script>

<template>
  <Field.Root :options="{ name: 'plan' }">
    <Label :native-label="false">Plan</Label>
    <Radio.Group v-model="plan">
      <div v-for="option in plans" :key="option.value">
        <Radio.Root :id="`plan-${option.value}`" :value="option.value">
          <Radio.Indicator />
        </Radio.Root>
        <Label :id="`plan-${option.value}-label`" :for="`plan-${option.value}`">
          {{ option.label }}
        </Label>
      </div>
    </Radio.Group>
  </Field.Root>
</template>

Examples

Labelling

Radio.Group is not a labelable element, so a Label inside a Field has to opt out of rendering a <label> and name the group through aria-labelledby instead.

<template>
  <Field.Root :options="{ name: 'plan' }">
    <Label :native-label="false">Plan</Label>
    <Radio.Group v-model="plan">
      <Radio.Root id="plan-hobby" value="hobby" />
      <Label id="plan-hobby-label" for="plan-hobby">Hobby</Label>
    </Radio.Group>
  </Field.Root>
</template>

Orientation and direction

orientation decides which arrow keys move between items. It is 'both' by default, so ArrowUp / ArrowDown and ArrowLeft / ArrowRight all move; narrowing it to 'horizontal' or 'vertical' leaves the other pair to whatever else is on the page. dir swaps ArrowLeft and ArrowRight; unset, it follows a DirectionProvider or the surrounding dir attribute, as described in Composition.

<template>
  <Radio.Group
    v-model="alignment"
    :options="{ orientation: 'horizontal', dir: 'rtl' }"
  >
    <Radio.Root value="start" />
    <Radio.Root value="center" />
    <Radio.Root value="end" />
  </Radio.Group>
</template>

Disabling

An item is disabled when either the group or the item says so, and a disabled item is skipped by the arrow keys as well as being unselectable.

<template>
  <Radio.Group v-model="plan">
    <Radio.Root value="hobby" />
    <Radio.Root value="team" />
    <Radio.Root value="enterprise" disabled />
  </Radio.Group>
</template>

readOnly blocks selection while keeping every item focusable, so the value can still be read and copied. Both readOnly and required also sit on Radio.Root, where an item’s own value wins over the group’s.

<template>
  <Radio.Group v-model="plan" :options="{ readOnly: true }">
    <Radio.Root value="hobby" />
    <Radio.Root value="team" :read-only="false" />
  </Radio.Group>
</template>

Inside a form

Radio.Group renders one visually hidden radio input per registered item, all sharing the group’s name, so the form sees it.

<template>
  <form @submit.prevent="submit">
    <Radio.Group v-model="plan" :options="{ name: 'plan', required: true }">
      <Radio.Root value="hobby" />
      <Radio.Root value="team" />
    </Radio.Group>
  </form>
</template>

Without a name, from either the group or the field, nothing is submitted at all. A group rendered outside the <form> it belongs to points at it by id through form, which reaches every hidden input.

The inputs themselves are exposed as inputs on the group instance, in registration order, for the rare case where you need to read validity or call setCustomValidity() on one of them.

Styling from state

Paint the item from data-state. If you want the dot to scale in rather than appear, transition on Radio.Indicator names the transition it enters and leaves under, and defaults to mirror-radio-indicator. Where the dot should never unmount, force-mount keeps it in the DOM and you animate it from data-state instead. Both are set on the indicator itself, so two items in one group can differ.

<template>
  <Radio.Group v-model="plan">
    <Radio.Root value="team" class="radio">
      <Radio.Indicator class="radio-dot" force-mount />
    </Radio.Root>
  </Radio.Group>
</template>

<style>
.radio[data-state='checked'] {
  border-color: currentcolor;
}

.radio[data-disabled='true'] {
  opacity: 0.5;
}
</style>

API reference

Module. A bundled options object, and useMirrorRadio(id) as the programmatic API.

Radio.Group

Renders a <div role="radiogroup"> plus one visually hidden <input type="radio"> per registered item.

Props

PropTypeDefault
id
stringgenerated
modelValue
string | number | undefinedundefined
defaultValue
string | number | nullnull
options
RadioOptionssee below

Options

OptionTypeDefault
name
stringinherited from Field
form
stringundefined
orientation
'horizontal' | 'vertical' | 'both''both'
dir
'ltr' | 'rtl'inherited
loop
booleantrue
disabled
booleaninherited from Field
readOnly
booleaninherited from Field
required
booleaninherited from Field

Emits

EmitPayload
update:modelValue
string | number

Slot props

The field state set, plus the one below.

PropType
value
string | number | null

Radio.Group also exposes inputs, the hidden <input type="radio"> elements in registration order, on its instance.

Radio.Root

Renders a <button role="radio">, registering itself with the group on mount and unregistering on unmount.

Props

PropTypeDefault
id
stringgenerated
value
string | numbernone
disabled
booleanfalse
readOnly
booleaninherited from the group
required
booleaninherited from the group

Slot props

The field state set, with disabled, readOnly, required, filled and focused narrowed to this item, plus the one below.

PropType
checked
boolean

Radio.Indicator

Renders a <span> while the item is selected. Both props below are set on the indicator itself. To animate every indicator in a group the same way, spread one object over each of them.

<script setup lang="ts">
const dot = { transition: 'plan-dot' }
</script>

<template>
  <Radio.Group v-model="plan">
    <Radio.Root value="team">
      <Radio.Indicator v-bind="dot" />
    </Radio.Root>
    <Radio.Root value="solo">
      <Radio.Indicator v-bind="dot" />
    </Radio.Root>
  </Radio.Group>
</template>

Props

PropTypeDefault
forceMount
booleanfalse
transition
string'mirror-radio-indicator'

Slot props

The same as Radio.Root.

Composable

useMirrorRadio(id) reaches a group from anywhere in the app.

KeyType
value
ComputedRef<RadioValue | null>
values
ComputedRef<Array<RadioValue>>
disabled
ComputedRef<boolean>
readOnly
ComputedRef<boolean>
select
(next: RadioValue) => void

Data attributes

Every part writes the field state set.

PartAttributeValue
all
data-disabled
true
all
data-readonly
true
all
data-required
true
all
data-valid
true
all
data-invalid
true
all
data-dirty
true
all
data-touched
true
all
data-filled
true
all
data-focused
true
all
data-orientation
horizontal | vertical | both
Radio.Root
data-state
checked | unchecked
Radio.Root
data-checked
true
Radio.Indicator
data-state
checked | unchecked
Radio.Indicator
data-checked
true

CSS variables

PartVariableDefault
Radio.Root
--mirror-radio-cursor
pointer
Radio.Root
--mirror-radio-disabled-cursor
not-allowed
Radio.Indicator
--mirror-radio-indicator-pointer-events
none

Errors

Code
missing_radio_group_context
missing_context
missing_radio_context
duplicate_radio_value

Accessibility

The group is role="radiogroup" and each item role="radio" with aria-checked, and the whole group is a single tab stop that focus and selection travel through together.

KeyBehaviour
TabEnters the group at the selected item, else the first enabled item. A second press leaves the group.
ArrowDown / ArrowUpMoves to and selects the next or previous enabled item. Inert while orientation is 'horizontal'.
ArrowRight / ArrowLeftMoves to and selects the next or previous enabled item. Swapped under dir="rtl", and inert while orientation is 'vertical'.
Home / EndMoves to and selects the first or last enabled item, in either orientation.
SpaceSelects the focused item.
EnterNothing. The default is blocked, so the group never submits its form.

Disabled items are skipped throughout, and the ends wrap while loop is on and stop while it is off.