Toolbar collapsible
A vertical icon rail where pressing a button opens a panel above the rail —
animated with height: 0 → auto plus a slight scale, so it unfolds out of the
button rather than appearing in place.
Pressing the active button again closes the panel, and a mousedown on
document outside the toolbar dismisses it. The panel is keyed by item id, so
switching between two items cross-animates rather than snapping.
Usage
Declare your items as objects with id, label, an optional icon and
optional content. Use the item-content slot to render the open panel and the
item-icon slot to customise a button's contents; both receive the item.
<template>
<div class="flex w-full max-w-md justify-center py-4">
<MToolbarExpandable
:items="items"
@select="selected = $event.label"
>
<template #item-icon="{ item }">
<UIcon
:name="item.icon"
class="size-5"
/>
</template>
<template #item-content="{ item }">
<div class="flex items-center gap-3">
<img
:src="`https://picsum.photos/seed/${item.id}/80/80`"
:alt="item.label"
class="size-12 rounded-xl object-cover"
>
<div class="space-y-0.5">
<p class="text-sm font-semibold">
{{ item.label }}
</p>
<p class="text-xs text-muted">
{{ item.content }}
</p>
</div>
</div>
</template>
</MToolbarExpandable>
</div>
</template>
<script setup lang="ts">
const selected = ref('none')
const items = [
{ id: 'ada', label: 'Ada', icon: 'i-lucide-user', content: 'Analytical engine notes' },
{ id: 'linus', label: 'Linus', icon: 'i-lucide-terminal', content: 'Kernel patch series' },
{ id: 'grace', label: 'Grace', icon: 'i-lucide-ship', content: 'Compiler design notes' },
]
</script>
Examples
Panels from item content
Skip the item-content slot and give each item its own content string; the
panel falls back to rendering it.
Each button opens a panel showing its own `content` string — no `item-content` slot needed.
<template>
<div class="flex w-full max-w-sm flex-col items-center gap-6">
<MToolbarExpandable :items="items" />
<p class="text-sm text-muted">
Each button opens a panel showing its own `content` string — no
`item-content` slot needed.
</p>
</div>
</template>
<script setup lang="ts">
const items = [
{ id: 'profile', label: 'Profile', icon: 'i-lucide-user', content: 'View and edit your public profile.' },
{ id: 'team', label: 'Team', icon: 'i-lucide-users', content: 'Invite teammates and manage their roles.' },
{ id: 'billing', label: 'Billing', icon: 'i-lucide-credit-card', content: 'Update your payment method.' },
]
</script>
Customize
Customize
No customizable props detected for this component.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | ToolbarItem[] | [] | The buttons to render. id is required and doubles as the identity used to
open, close and re-key the panel — two items sharing an id will fight over
the same panel. label is passed to the item-icon and item-content slots
but is not used for any accessible name on the button itself. icon is applied
as the button's icon prop, and content is only used as the panel's
fallback when no item-content slot is provided. |
class | string | '' | Merged onto the outer wrapper that holds both the rail and the panel, after its
own relative inline-flex flex-col-reverse classes. |
Events
| Event | Payload | Description |
|---|---|---|
select | (item: ToolbarItem) => void | Emitted with the full item when a button opens its panel. Pressing the already
active button closes it and does not emit, as does dismissing with an
outside click — so select means "opened", not "toggled". |
Slots
| Slot | Props | Description |
|---|---|---|
item-icon | { item: ToolbarItem, isActive: boolean } | Rendered inside each button, so it sits next to whatever icon produced rather
than replacing it — use it for badges or counts, not for the glyph itself.
isActive reflects the current open state. |
item-content | { item: ToolbarItem } | The open panel's contents, receiving the active item. Falls back to rendering
the active item's content string when the slot is empty. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.toolbarExpandable in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | relative inline-flex flex-col-reverse items-center gap-2 |
bar | - | flex items-center gap-1 h-12 px-2 rounded-2xl bg-default/80 dark:bg-default/80 backdrop-blur-xl border border-default/10 dark:border-default/10 shadow-2xl shadow-black/20 |
button | - | flex items-center justify-center w-10 h-10 rounded-xl |
panel | - | w-full rounded-2xl bg-default/90 dark:bg-default/90 backdrop-blur-xl border border-default/10 dark:border-default/10 shadow-2xl shadow-black/20 overflow-hidden |
panelContent | - | p-4 |
content | - | text-sm text-muted dark:text-muted |
Accessibility
Each button is a UButton with an icon and no label, so it has no
accessible name unless you supply one through the item-icon slot or your own
attributes. The active item is only distinguished visually, by a
bg-black/10 dark:bg-default/10 class — there is no aria-pressed,
aria-expanded or aria-controls on the button, and the panel has no role,
no heading and no id to reference.
Because item.label never reaches an ARIA attribute, the practical fix is to
give every item an icon that reads unambiguously and add your own label in the
slot markup.
Dismissal is a mousedown listener on document: there is no Escape handler
and no focus management, so a keyboard user can open a panel but not close it
with the keyboard.
There is no prefers-reduced-motion branch — the unfold spring plays regardless.
Performance
activeItemis acomputedthat finds the open item byid, so the panel renders from a single source of truth and thev-ifis a plain truthiness check rather than a per-render search.- The panel is keyed by
activeItem.idinside anAnimatePresence, which is what lets one panel exit and the next enter as two separate animations. - Animating
height: 0 → autois a layout-affecting property.AnimatePresenceis given:initial="false"so the first panel opened does not animate in from nothing — only subsequent opens do. - One
mousedownlistener, attached on mount and removed on unmount. No observers, no timers, no animation frames owned by the component.
flex-col-reverse, which is why the panel appears above the rail
rather than below it — reversing the column keeps the rail's DOM position (and
therefore its natural place in tab order) below a panel that is painted first.
That also means the rail sits at the visual bottom of the component's box, so
give the wrapper bottom padding if the panel would otherwise collide with the
viewport edge.