Navigation

Getting between places and through sequences. Every one of these is fully operable from the keyboard — arrows move, Home and End jump to the ends, and disabled entries are skipped rather than landed on.

Accordion

The value is a string[] whether or not multiple is on — in single mode it simply holds zero or one key. A value whose shape changes with a prop forces every consumer to write two branches for one component.

Single and multiple

One 20 kg bag in the hold, one small bag in the cabin.

Open the other section and this one stays put.

<UiAccordion v-model="open" :items="items" multiple>
PropTypeDefaultDescription
modelValuestring[][]Open keys. Always an array, even in single mode.
itemsAccordionItem[]Required. { key, label, icon?, disabled? }
multiplebooleanfalseLets more than one section stay open.
@toggle(key: string, open: boolean) => voidFires on every open and close.

Breadcrumb

The last crumb is never a link, even when you give it a to — it is the page you are already on, and it carries aria-current="page" instead. When the trail is collapsed, the first and last crumbs always survive: losing both the root and your position at once is the fastest way to strand someone.

Full, collapsed, custom separator

<UiBreadcrumb :items="items" :max-items="3" />
PropTypeDefaultDescription
itemsCrumbItem[]Required. { label, to?, icon? }
maxItemsnumber0Collapses the middle above this count. 0 never collapses.
separatorstring''Text separator. Empty uses a chevron icon.

Command palette

Results are grouped only while the query is empty. As soon as you type, they are ranked — a title match beats a group match beats a path match — and entries from one group are no longer adjacent. Grouping them anyway would make the list read in a different order than the arrow keys move through it, which is the quickest way to make a keyboard feel broken. While searching, each row carries its group as a trailing caption instead.

It follows the ARIA combobox pattern: the input keeps focus and owns the keyboard, while aria-activedescendant points at the highlighted row. Escape is deliberately left to the shared dismiss handler, so it closes the dialog rather than just the list inside it.

Open the palette

<UiCommandPalette v-model:open="open" v-model:query="query" :items="items" @select="go" />
PropTypeDefaultDescription
itemsCommandItem[]Required. { label, to, group, icon? } — build it from your nav with commandsFrom().
v-model:openbooleanfalseWhether the palette is showing.
v-model:querystring''The search text. Set it before opening to seed the palette with what the user already typed.
placeholderstring'Search pages…'Placeholder of the search input.
emptyTextstring'No page matches'Shown with the query echoed back when nothing matches.
labelstring'Search pages'Accessible name of the dialog, the input, and the listbox.
@select(item: CommandItem)Fired with the chosen entry. The palette closes itself; you do the navigating.

NotificationBell

Built on Popover — the anchoring, click-outside, and Escape handling are never duplicated here. The unread count comes from counting read: false items, not from a separate prop the caller has to keep in sync; a count above maxCount shows as "9+" so the badge never outgrows the bell. The trigger's accessible name spells out the unread count itself, because a bare number badge reads as nothing to a screen reader.

With notifications

<UiNotificationBell :items="items" view-all-label="View all" @select="onSelect" @mark-all-read="onMarkAllRead" />

Empty

<UiNotificationBell :items="[]" />
PropTypeDefaultDescription
itemsNotificationItem[]Required. { id, title, description?, time?, read?, icon?, tone? }
labelstring'Notifications'Accessible name, also used as the panel title and part of the trigger name.
emptyTextstring'All caught up'Shown instead of the list when items is empty.
maxCountnumber9Badge shows "N+" above this count instead of growing sideways.
viewAllLabelstringAdds a footer link. Omit it and the footer disappears.
@select(item: NotificationItem) => voidFires when a row is clicked; closes the panel.
@mark-all-read() => voidFires when "Mark all read" is pressed. Marking items read is the caller's job.
@view-all() => voidFires when the footer link is pressed.

Pagination

Give it the number of items, not the number of pages — that is the shape your API already hands you, and letting each caller divide it themselves means each one gets the rounding wrong differently. An ellipsis only appears when it hides more than one page: collapsing a single page behind “…” costs more width than the number it replaces.

Short and long ranges

<UiPagination v-model:page="page" :total="95" :per-page="10" show-summary />
PropTypeDefaultDescription
pagenumber1Current page. Two-way with v-model:page.
totalnumberRequired. Number of ITEMS, not pages.
perPagenumber10Items per page.
siblingsnumber1Neighbours shown either side of the current page.
showEdgesbooleanfalseAdds jump-to-first and jump-to-last buttons.
showSummarybooleanfalseShows "21–30 of 95".
@change(page: number) => voidFires only when the page actually changes.

Steps

Where someone is in a sequence. With clickable on, only completed steps respond — jumping forward past work that is not finished is a flow bug, not a convenience, so the component refuses rather than leaving it to each page to remember.

Horizontal and vertical

  1. 2Choose seats2 of 40 left
  2. 3PaymentTransfer or QRIS
  3. 4Ticket issued
  1. Passenger detailsName and contact
  2. 2Choose seats2 of 40 left
  3. 3PaymentTransfer or QRIS
  4. 4Ticket issued
<UiSteps :items="items" :current="1" clickable @select="…" />
PropTypeDefaultDescription
itemsStepItem[]Required. { label, description?, icon? }
currentnumber0Zero-based index of the step being worked on.
orientation'horizontal' | 'vertical''horizontal'Vertical suits sidebars and narrow screens.
clickablebooleanfalseLets the user go back to a completed step.
@select(index: number) => voidOnly fires for steps already completed.

Tabs

Arrow keys move between tabs and select as they go, Home and End jump to the ends, and disabled tabs are skipped rather than landed on. Focus follows the selection — unlike the menu in Dropdown, where focus stays on the button. That is the ARIA pattern for a tablist, not an inconsistency: a tab is a destination, a menu item is an action.

Underline and pills

318 tickets sold, 86% of seats filled.

Figures for today.

<UiTabs v-model="active" :items="items"><template #overview>…</template></UiTabs>
PropTypeDefaultDescription
modelValuestring''Active tab key. Two-way with v-model.
itemsTabItem[]Required. { key, label, icon?, badge?, disabled? }
variant'underline' | 'pills''underline'Underline for page sections, pills for compact switches.
fullWidthbooleanfalseStretches tabs to fill the row.
@change(key: string) => voidFires only when the key actually changes.