Toolbar
A row of buttons, links and controls behind a single tab stop.
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.
<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.Rootanaria-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’sid. - 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.Inputin 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.
| Name | Required | Description |
|---|---|---|
| Toolbar.Root | true | Renders a <div role="toolbar"> and owns the single tab stop. |
| Toolbar.Button | Renders a <button>. One arrow-key stop. | |
| Toolbar.Link | Renders an <a>. One arrow-key stop. | |
| Toolbar.Input | Renders an <input>. One arrow-key stop that keeps the caret keys. | |
| Toolbar.Group | Renders a <div role="group"> and passes its disabled state down. | |
| Toolbar.Separator | Renders 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
Options
Slot props
Toolbar.Button
Renders a <button> and takes one place in the arrow-key order.
Props
Slot props
Toolbar.Link
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
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
Slot props
Toolbar.Separator
Renders a <div role="separator">, oriented across the toolbar rather than
along it. Not an arrow-key stop.
Props
Slot props
Toolbar.Input
Renders an <input> and takes one place in the arrow-key order, keeping the
keys that move its caret.
Props
Emits
Composable
useMirrorToolbar(id) reaches a Toolbar from anywhere in the app.
Data attributes
CSS variables
Errors
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.
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.