Skip to content

Toolbar

A row of buttons, links and controls behind a single tab stop.

View source View as Markdown

Toolbar gathers buttons, links and controls into one group that the keyboard enters once, with the arrow keys moving between the items inside it. Useful for a formatting bar above an editor, or anywhere a cluster of controls would otherwise cost a keyboard user one Tab each.

App.vue
<template>
  <Toolbar.Root :class="toolbar" aria-label="Text formatting">
    <Toolbar.Button
      v-for="format in formats"
      :key="format.label"
      :aria-label="format.label"
      :aria-pressed="format.pressed"
      :class="[button, format.glyphClass]"
      @click="format.pressed = !format.pressed"
    >
      {{ format.glyph }}
    </Toolbar.Button>

    <Toolbar.Separator :class="separator" />

    <Toolbar.Group :class="group" aria-label="Alignment">
      <Toolbar.Button
        v-for="option in alignments"
        :key="option.value"
        :aria-label="option.label"
        :aria-pressed="alignment === option.value"
        :class="button"
        @click="alignment = option.value"
      >
        <svg class="size-4.5" viewBox="0 0 18 18" aria-hidden="true">
          <path
            :d="option.path"
            fill="none"
            stroke="currentColor"
            stroke-width="1.5"
            stroke-linecap="round"
          />
        </svg>
      </Toolbar.Button>
    </Toolbar.Group>

    <Toolbar.Link :class="link" href="#accessibility">Help</Toolbar.Link>
  </Toolbar.Root>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Toolbar } from '@maas/mirror/vue'

const toolbar =
  'border-surface rounded-[calc(var(--radius-component-md)+0.375rem+1px)] flex w-full max-w-md items-center gap-1 border p-1.5'

const button = [
  'inline-flex size-9 shrink-0 items-center justify-center',
  'rounded-component-md border-2 border-[transparent] type-component-md',
  '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',
  'aria-[pressed=true]:bg-primary-solid aria-[pressed=true]:text-primary-on-solid',
  'aria-[pressed=true]:hover:bg-primary-solid-hover',
  'data-[disabled=true]:text-disabled-on-subtle',
].join(' ')

const separator = 'border-surface mx-1 h-6 self-center border-l'

const group = 'flex items-center gap-1'

const link = [
  'rounded-component-md type-component-sm text-surface-link ml-auto',
  'border-2 border-[transparent] px-2.5 py-1.5 no-underline',
  'transition-all duration-100 ease-linear',
  'outline-4 outline-transparent focus-visible:focus-ring',
  'hover:text-surface-link-hover active:text-surface-link-active',
].join(' ')

const formats = ref([
  { label: 'Bold', glyph: 'B', glyphClass: 'font-bold', pressed: true },
  { label: 'Italic', glyph: 'I', glyphClass: 'italic', pressed: false },
  { label: 'Underline', glyph: 'U', glyphClass: 'underline', pressed: false },
])

const alignments = [
  { value: 'left', label: 'Align left', path: 'M3 5h12M3 9h7M3 13h10' },
  { value: 'center', label: 'Align centre', path: 'M3 5h12M6 9h6M4 13h10' },
  { value: 'right', label: 'Align right', path: 'M3 5h12M8 9h7M5 13h10' },
]

const alignment = ref('left')
</script>

Usage guidelines

  • Give Toolbar.Root an aria-label, since a screen reader announces the toolbar by that name before it announces anything inside it.
  • Keep every item inside Toolbar.Root. The arrow-key order is the root’s own subtree, so an item rendered elsewhere has nothing to move within, even when it carries the toolbar’s id.
  • The toolbar does not decide what a button does. Bold, italic and the rest keep their own pressed state and their own aria-pressed, as the demo above does.
  • Use at most one Toolbar.Input in a horizontal toolbar, and put it last, since the left and right arrow keys have to be shared between the caret and the toolbar.

Anatomy

Assemble the parts inside one root.

NameRequiredDescription
Toolbar.Root true Renders a <div role="toolbar"> and owns the single tab stop.
Toolbar.ButtonRenders a <button>. One arrow-key stop.
Toolbar.LinkRenders an <a>. One arrow-key stop.
Toolbar.InputRenders an <input>. One arrow-key stop that keeps the caret keys.
Toolbar.GroupRenders a <div role="group"> and passes its disabled state down.
Toolbar.SeparatorRenders a <div role="separator">. Not an arrow-key stop.
<script setup lang="ts">
import { Toolbar } from '@maas/mirror/vue'
</script>

<template>
  <Toolbar.Root aria-label="Text formatting">
    <Toolbar.Button>B</Toolbar.Button>
    <Toolbar.Button>I</Toolbar.Button>
    <Toolbar.Separator />
    <Toolbar.Group aria-label="Alignment">
      <Toolbar.Button>Left</Toolbar.Button>
      <Toolbar.Button>Right</Toolbar.Button>
    </Toolbar.Group>
    <Toolbar.Link href="/help">Help</Toolbar.Link>
  </Toolbar.Root>
</template>

Examples

Moving between items

orientation decides which arrow keys move, and is written to data-orientation on every part, so lay the toolbar out from that attribute rather than from a class of your own.

<template>
  <Toolbar.Root :options="{ orientation: 'vertical' }" aria-label="Tools">
    <Toolbar.Button>Pen</Toolbar.Button>
    <Toolbar.Button>Eraser</Toolbar.Button>
  </Toolbar.Root>
</template>

Arrow keys wrap at the ends. Set loopFocus 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.

<template>
  <Toolbar.Root :options="{ loopFocus: false, dir: 'rtl' }" />
</template>

Disabling items

disabled in the root’s options disables every item inside it, and disabled on a Toolbar.Group disables the items in that group. An item that sets disabled itself wins over both, so :disabled="false" is how one button stays usable in a disabled group.

<template>
  <Toolbar.Root :options="{ disabled: true }">
    <Toolbar.Button>Cut</Toolbar.Button>
    <Toolbar.Button :disabled="false">Undo</Toolbar.Button>
  </Toolbar.Root>
</template>

A disabled button keeps its place in the arrow-key order and carries aria-disabled rather than the native disabled attribute, so a screen reader still reaches it and says why it cannot be used. Set focusableWhenDisabled to false where you would rather it were skipped entirely, and the native attribute comes back with it.

<template>
  <Toolbar.Button :focusable-when-disabled="false" disabled>
    Paste
  </Toolbar.Button>
</template>

Toolbar.Link never takes the disabled state, so wrap it in v-if where it should not be available.

Separating items

Toolbar.Separator runs across the toolbar rather than along it, so it reports the opposite orientation to everything else. Draw it from data-orientation and one rule covers both.

<template>
  <Toolbar.Separator class="separator" />
</template>

<style>
.separator[data-orientation='vertical'] {
  width: 1px;
  align-self: stretch;
  background: var(--app-color-surface-border);
}

.separator[data-orientation='horizontal'] {
  height: 1px;
  background: var(--app-color-surface-border);
}
</style>

It is not an arrow-key stop, so the keys pass straight over it.

An input in the toolbar

Toolbar.Input renders a native input and takes a v-model. The arrow keys move the caret while there is text left to travel, and only reach the toolbar once the caret sits collapsed against the edge it is heading for. Home and End always stay with the text.

<template>
  <Toolbar.Root aria-label="Formatting">
    <Toolbar.Button>B</Toolbar.Button>
    <Toolbar.Input v-model="size" aria-label="Font size" />
  </Toolbar.Root>
</template>

<script setup lang="ts">
const size = ref('16')
</script>

That sharing is why one input, placed last, is the sensible limit in a horizontal toolbar. A vertical toolbar navigates on the up and down arrows, which the caret never uses, so the same limit does not apply.

Styling from state

Every part writes its state to data-* attributes, and the item holding the tab stop carries data-highlighted, so a toolbar can show where the keyboard is without a class of your own.

<template>
  <Toolbar.Button class="button">B</Toolbar.Button>
</template>

<style>
.button[data-highlighted='true'] {
  background: var(--app-color-primary-bg-subtle);
}

.button[data-disabled='true'][data-focusable='true'] {
  opacity: 0.4;
}
</style>

Nesting another roving group

Toolbar.Root is the roving-focus group, and any descendant that registers as a roving-focus item joins the same order. In practice that means a nested group renders its items through Toolbar.Button with asChild, and the toolbar keeps one tab stop across both.

<template>
  <Toolbar.Root aria-label="Formatting">
    <Toolbar.Button>B</Toolbar.Button>
    <Toolbar.Group aria-label="Alignment">
      <Toolbar.Button as-child>
        <OtherGroupItem value="left">Left</OtherGroupItem>
      </Toolbar.Button>
    </Toolbar.Group>
  </Toolbar.Root>
</template>

Reaching the toolbar from elsewhere

Give the root an id and useMirrorToolbar(id) reads the same state from anywhere in the app, which is how a menu item or a shortcut hands focus back to the toolbar.

<template>
  <Toolbar.Root id="editor-toolbar" aria-label="Formatting" />
</template>

<script setup lang="ts">
const { orientation, currentItemId, focusFirst } =
  useMirrorToolbar('editor-toolbar')
</script>

API reference

Module. A bundled options object, and useMirrorToolbar(id) as the programmatic API. Every part takes id to resolve a Toolbar it is not nested inside, though the items themselves have to stay in the root’s subtree to take part in the arrow-key order.

Toolbar.Root

Renders a <div role="toolbar">.

Props

PropTypeDefault
id
stringgenerated
options
ToolbarOptionssee below

Options

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

Slot props

PropType
orientation
'horizontal' | 'vertical'
disabled
boolean

Toolbar.Button

Renders a <button> and takes one place in the arrow-key order.

Props

PropTypeDefault
id
stringinjected
elementId
stringgenerated
disabled
booleaninherited
focusableWhenDisabled
booleantrue
type
'button' | 'submit' | 'reset''button'

Slot props

PropType
disabled
boolean
focusable
boolean
highlighted
boolean
orientation
'horizontal' | 'vertical'

Renders an <a> and takes one place in the arrow-key order. Takes id, elementId and the primitive props, and never takes the disabled state.

Slot props

PropType
highlighted
boolean
orientation
'horizontal' | 'vertical'

Toolbar.Group

Renders a <div role="group">. It is not an arrow-key stop of its own; it passes its disabled state to the items inside it.

Props

PropTypeDefault
id
stringinjected
disabled
booleaninherited

Slot props

PropType
disabled
boolean
orientation
'horizontal' | 'vertical'

Toolbar.Separator

Renders a <div role="separator">, oriented across the toolbar rather than along it. Not an arrow-key stop.

Props

PropTypeDefault
id
stringinjected
orientation
'horizontal' | 'vertical'the toolbar’s, turned by a quarter

Slot props

PropType
orientation
'horizontal' | 'vertical'

Toolbar.Input

Renders an <input> and takes one place in the arrow-key order, keeping the keys that move its caret.

Props

PropTypeDefault
id
stringinjected
elementId
stringgenerated
modelValue
string | number | undefinedundefined
defaultValue
string | number''
type
string'text'
name
stringundefined
disabled
booleaninherited
focusableWhenDisabled
booleantrue

Emits

EmitPayload
update:modelValue
string | number

Composable

useMirrorToolbar(id) reaches a Toolbar from anywhere in the app.

KeyType
orientation
ComputedRef<ToolbarOrientation>
disabled
ComputedRef<boolean>
dir
ComputedRef<ToolbarTextDirection>
itemIds
ComputedRef<Array<string>>
currentItemId
ComputedRef<string | null>
focusItem
(itemId: string) => void
focusFirst
() => void

Data attributes

PartAttributeValue
all
data-orientation
horizontal | vertical
Toolbar.Root, Toolbar.Group, Toolbar.Button, Toolbar.Input
data-disabled
true
Toolbar.Button, Toolbar.Input
data-focusable
true
Toolbar.Button, Toolbar.Link, Toolbar.Input
data-highlighted
true

CSS variables

VariableDefault
--mirror-toolbar-button-cursor
pointer
--mirror-toolbar-button-disabled-cursor
not-allowed
--mirror-toolbar-button-user-select
none
--mirror-toolbar-button-tap-highlight-color
transparent
--mirror-toolbar-input-disabled-cursor
not-allowed

Errors

Code
missing_toolbar_context
missing_context

Accessibility

The root is a role="toolbar" with aria-orientation, the group a role="group", the separator a role="separator", and the whole toolbar is one tab stop that follows the last item to hold focus.

KeyBehaviour
TabMoves into the toolbar at the item that last held focus, then out of it entirely.
ArrowRight / ArrowLeftHorizontal toolbar only. Moves to the next or previous item, swapped under dir="rtl". Wraps while loopFocus is on.
ArrowDown / ArrowUpVertical toolbar only. Moves to the next or previous item, with the same wrapping.
Home / EndMoves to the first or last item, in either orientation, ignoring loopFocus. Inside a Toolbar.Input they move the caret instead.
Enter / SpaceActivates the focused button, as a button normally would. A non-native button gets the same two keys supplied for it.

A disabled item stays in the order by default and carries aria-disabled, so it is announced rather than skipped in silence. Toolbar.Root itself is tabindex="-1", which is what keeps the toolbar to one tab stop no matter how many items it holds.