Skip to content

Toggle Group

Shares one pressed state between a row of toggles, one at a time or several.

View source View as Markdown

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.

App.vue
<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.Root inside a group needs a value, 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-label describing what the toggles have in common.
  • Where only one option can be on and one always has to be, use Radio instead. 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.

NameRequiredDescription
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

PropTypeDefault
id
stringgenerated
modelValue
string[] | undefinedundefined
defaultValue
string[][]
options
ToggleGroupOptionssee below

Options

OptionTypeDefault
multiple
booleanfalse
disabled
booleanfalse
orientation
'horizontal' | 'vertical''horizontal'
dir
'ltr' | 'rtl'inherited
loop
booleantrue

Emits

EmitPayload
update:modelValue
string[]

Slot props

PropType
value
string[]
orientation
'horizontal' | 'vertical'
multiple
boolean
disabled
boolean

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.

KeyType
value
ComputedRef<ToggleGroupValue[]>
orientation
ComputedRef<ToggleGroupOrientation>
multiple
ComputedRef<boolean>
disabled
ComputedRef<boolean>
isPressed
(value: ToggleGroupValue) => boolean
toggle
(value: ToggleGroupValue) => void
setValue
(value: ToggleGroupValue[]) => void

Data attributes

PartAttributeValue
Toggle.Group
data-orientation
horizontal | vertical
Toggle.Group, Toggle.Root
data-disabled
true
Toggle.Group
data-multiple
true
Toggle.Root
data-state
on | off
Toggle.Root
data-pressed
true

CSS variables

None of its own. Toggle.Root keeps the three it always has.

Errors

Code
missing_item_value

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.

KeyBehaviour
TabMoves into the group at the last focused toggle, then out again.
ArrowRight / ArrowLeftHorizontal group only. Moves to the next or previous enabled toggle, swapped under dir="rtl". Wraps while loop is on.
ArrowDown / ArrowUpVertical group only. Moves to the next or previous enabled toggle, with the same wrapping.
Home / EndMoves to the first or last enabled toggle, in either orientation, ignoring loop.
Enter / SpacePresses the focused toggle. Moving focus never presses anything on its own.

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.