Toggle Group
Shares one pressed state between a row of toggles, one at a time or several.
Toggle Group puts a set of Toggle.Root buttons under
one value, so pressing one can unpress the others. Useful for a choice with a
handful of options that all fit on screen, like text alignment, and for a row
of formatting buttons where several can be on at once.
<template>
<div class="flex flex-col items-start gap-4">
<Toggle.Group v-model="alignment" aria-label="Text alignment" :class="group">
<Toggle.Root
v-for="entry in alignments"
:key="entry.value"
:class="toggle"
:value="entry.value"
>
{{ entry.label }}
</Toggle.Root>
</Toggle.Group>
<Toggle.Group
v-model="formats"
aria-label="Formatting"
:class="group"
:options="{ multiple: true }"
>
<Toggle.Root
v-for="entry in formatting"
:key="entry.value"
:aria-label="entry.label"
:class="[toggle, entry.glyphClass]"
:value="entry.value"
>
{{ entry.glyph }}
</Toggle.Root>
</Toggle.Group>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Toggle } from '@maas/mirror/vue'
const alignment = ref(['left'])
const formats = ref(['bold'])
const alignments = [
{ value: 'left', label: 'Left' },
{ value: 'centre', label: 'Centre' },
{ value: 'right', label: 'Right' },
]
const formatting = [
{ value: 'bold', label: 'Bold', glyph: 'B', glyphClass: 'font-bold' },
{ value: 'italic', label: 'Italic', glyph: 'I', glyphClass: 'italic' },
{
value: 'underline',
label: 'Underline',
glyph: 'U',
glyphClass: 'underline',
},
]
const group =
'rounded-[calc(var(--radius-component-md)+0.25rem+1px)] border-surface flex gap-1 border p-1'
const toggle = [
'inline-flex h-9 min-w-9 items-center justify-center px-3',
'rounded-component-md border-2 border-[transparent] type-component-sm',
'text-primary-solid transition-all duration-100 ease-linear',
'outline-4 outline-transparent focus-visible:focus-ring',
'hover:bg-primary-subtle',
'active:bg-primary-subtle-active',
'data-[state=on]:bg-primary-solid data-[state=on]:text-primary-on-solid',
'data-[state=on]:hover:bg-primary-solid-hover',
'data-[state=on]:active:bg-primary-solid-active',
].join(' ')
</script>
Usage guidelines
- Every
Toggle.Rootinside a group needs avalue, since that is how the group tells one from another. A toggle without one throws rather than joining the group silently. - The group is one tab stop and has no name of its own, so give it an
aria-labeldescribing what the toggles have in common. - Where only one option can be on and one always has to be, use
Radioinstead. A single-select group can be emptied by pressing the toggle that is already on, which a radio group cannot.
Anatomy
Wrap the toggles. Toggle.Root is the same part you would render on its own.
| Name | Required | Description |
|---|---|---|
| Toggle.Group | true | Renders a <div role="group"> and is the roving-focus group. |
| Toggle.Root | true | Renders a <button>. Needs a value inside a group. |
<script setup lang="ts">
import { Toggle } from '@maas/mirror/vue'
</script>
<template>
<Toggle.Group v-model="alignment" aria-label="Text alignment">
<Toggle.Root value="left">Left</Toggle.Root>
<Toggle.Root value="centre">Centre</Toggle.Root>
<Toggle.Root value="right">Right</Toggle.Root>
</Toggle.Group>
</template>
Examples
One at a time, or several
The value is always an array. By default the group presses one toggle at a time, so pressing another swaps it and pressing the pressed one leaves the array empty.
<template>
<Toggle.Group v-model="alignment">
<Toggle.Root value="left">Left</Toggle.Root>
<Toggle.Root value="right">Right</Toggle.Root>
</Toggle.Group>
</template>
<script setup lang="ts">
const alignment = ref(['left'])
</script>
Set multiple and each toggle keeps its own state, collected into the same
array in document order.
<template>
<Toggle.Group v-model="formats" :options="{ multiple: true }">
<Toggle.Root value="bold" aria-label="Bold">B</Toggle.Root>
<Toggle.Root value="italic" aria-label="Italic">I</Toggle.Root>
</Toggle.Group>
</template>
An array with more than one value in a single-select group is cut down to its
first entry, and the shortened array is emitted back, so your v-model follows
what the group actually holds.
Moving between the toggles
The group is one tab stop, and the arrow keys move within it. orientation
decides which pair moves and is written to data-orientation, so lay the group
out from that attribute rather than from a class of your own.
<template>
<Toggle.Group :options="{ orientation: 'vertical' }" aria-label="Alignment">
<Toggle.Root value="left">Left</Toggle.Root>
<Toggle.Root value="right">Right</Toggle.Root>
</Toggle.Group>
</template>
Arrow keys wrap at the ends. Set loop to false to stop there instead, and
dir to mirror the horizontal keys. Unset, dir follows a
DirectionProvider or the surrounding dir attribute, as described in
Composition.
Disabling the group
disabled on the group disables every toggle in it, and reaches each one as
data-disabled so a single selector covers both.
<template>
<Toggle.Group :options="{ disabled: true }">
<Toggle.Root value="left">Left</Toggle.Root>
</Toggle.Group>
</template>
A single toggle can also be disabled on its own, which keeps the arrow keys moving past it.
Inside a Toolbar
A Toolbar is already the roving-focus group for
everything under it, so a group nested inside one hands the arrow keys over
rather than starting a second order. Nothing else changes: the value, the
attributes and the composable all behave the same.
<template>
<Toolbar.Root aria-label="Formatting">
<Toolbar.Button>Undo</Toolbar.Button>
<Toggle.Group v-model="formats" :options="{ multiple: true }">
<Toggle.Root value="bold" aria-label="Bold">B</Toggle.Root>
</Toggle.Group>
</Toolbar.Root>
</template>
Inside a form
The group renders no input of its own. Each Toggle.Root still carries its own
name, and renders the visually hidden checkbox it always would, so
the form sees it.
<template>
<form>
<Toggle.Group v-model="formats" :options="{ multiple: true }">
<Toggle.Root name="format" value="bold" aria-label="Bold">B</Toggle.Root>
<Toggle.Root name="format" value="italic" aria-label="Italic">
I
</Toggle.Root>
</Toggle.Group>
</form>
</template>
Reaching it from elsewhere
Give the group an id and useMirrorToggleGroup(id) reads and writes it from
anywhere in the app.
<script setup lang="ts">
import { useMirrorToggleGroup } from '@maas/mirror/vue'
const { value, toggle, setValue } = useMirrorToggleGroup('alignment')
</script>
API reference
Module. A bundled options object, and useMirrorToggleGroup(id) as the
programmatic API. The toggles it groups are
Toggle.Root, documented on its own page.
Toggle.Group
Renders a <div role="group">.
Props
Options
Emits
Slot props
Toggle.Root
The same part as Toggle.Root, with two differences
inside a group. value stops being optional, and modelValue and
defaultValue are ignored: the group owns the pressed state. Everything else,
including name and disabled, works as documented there.
Composable
useMirrorToggleGroup(id) reaches a group from anywhere in the app.
Data attributes
CSS variables
None of its own. Toggle.Root keeps the three it always has.
Errors
Accessibility
The group is a role="group" and each toggle keeps its own aria-pressed, so
a screen reader announces the pressed state per button rather than as a
selection. Give the group an aria-label; it has no name of its own.
The whole group is one tab stop. Every key below is handled on the focused
toggle, on keydown.
Arrow keys move focus without changing the value, which is where a toggle group
differs from Radio. Each toggle is its own button, so
nothing is pressed until the user presses it.