Radio
A group of mutually exclusive options, laid out so the user sees them all at once.
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.
<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
orientationto'horizontal'or'vertical'only where one axis belongs to something else, a slider or a scrolling panel beside the group. - Give every per-item
Labelits ownid, or inside aFieldthey share the field’s label ID and all register as its label.
Anatomy
Assemble the group, one Radio.Root per option.
| Name | Required | Description |
|---|---|---|
| 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.Indicator | Renders 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
Options
Emits
Slot props
The field state set, plus the one below.
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
Slot props
The field state set, with disabled, readOnly, required, filled and
focused narrowed to this item, plus the one below.
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
Slot props
The same as Radio.Root.
Composable
useMirrorRadio(id) reaches a group from anywhere in the app.
Data attributes
Every part writes the field state set.
CSS variables
Errors
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.
Disabled items are skipped throughout, and the ends wrap while loop is on and
stop while it is off.