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>| Prop | Type | Default | Description |
|---|---|---|---|
| modelValue | string[] | [] | Open keys. Always an array, even in single mode. |
| items | AccordionItem[] | — | Required. { key, label, icon?, disabled? } |
| multiple | boolean | false | Lets more than one section stay open. |
| @toggle | (key: string, open: boolean) => void | — | Fires 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" />| Prop | Type | Default | Description |
|---|---|---|---|
| items | CrumbItem[] | — | Required. { label, to?, icon? } |
| maxItems | number | 0 | Collapses the middle above this count. 0 never collapses. |
| separator | string | '' | 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" />| Prop | Type | Default | Description |
|---|---|---|---|
| items | CommandItem[] | — | Required. { label, to, group, icon? } — build it from your nav with commandsFrom(). |
| v-model:open | boolean | false | Whether the palette is showing. |
| v-model:query | string | '' | The search text. Set it before opening to seed the palette with what the user already typed. |
| placeholder | string | 'Search pages…' | Placeholder of the search input. |
| emptyText | string | 'No page matches' | Shown with the query echoed back when nothing matches. |
| label | string | '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="[]" />| Prop | Type | Default | Description |
|---|---|---|---|
| items | NotificationItem[] | — | Required. { id, title, description?, time?, read?, icon?, tone? } |
| label | string | 'Notifications' | Accessible name, also used as the panel title and part of the trigger name. |
| emptyText | string | 'All caught up' | Shown instead of the list when items is empty. |
| maxCount | number | 9 | Badge shows "N+" above this count instead of growing sideways. |
| viewAllLabel | string | — | Adds a footer link. Omit it and the footer disappears. |
| @select | (item: NotificationItem) => void | — | Fires when a row is clicked; closes the panel. |
| @mark-all-read | () => void | — | Fires when "Mark all read" is pressed. Marking items read is the caller's job. |
| @view-all | () => void | — | Fires 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 />| Prop | Type | Default | Description |
|---|---|---|---|
| page | number | 1 | Current page. Two-way with v-model:page. |
| total | number | — | Required. Number of ITEMS, not pages. |
| perPage | number | 10 | Items per page. |
| siblings | number | 1 | Neighbours shown either side of the current page. |
| showEdges | boolean | false | Adds jump-to-first and jump-to-last buttons. |
| showSummary | boolean | false | Shows "21–30 of 95". |
| @change | (page: number) => void | — | Fires 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
- 2Choose seats2 of 40 left
- 3PaymentTransfer or QRIS
- 4Ticket issued
- Passenger detailsName and contact
- 2Choose seats2 of 40 left
- 3PaymentTransfer or QRIS
- 4Ticket issued
<UiSteps :items="items" :current="1" clickable @select="…" />| Prop | Type | Default | Description |
|---|---|---|---|
| items | StepItem[] | — | Required. { label, description?, icon? } |
| current | number | 0 | Zero-based index of the step being worked on. |
| orientation | 'horizontal' | 'vertical' | 'horizontal' | Vertical suits sidebars and narrow screens. |
| clickable | boolean | false | Lets the user go back to a completed step. |
| @select | (index: number) => void | — | Only 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>| Prop | Type | Default | Description |
|---|---|---|---|
| modelValue | string | '' | Active tab key. Two-way with v-model. |
| items | TabItem[] | — | Required. { key, label, icon?, badge?, disabled? } |
| variant | 'underline' | 'pills' | 'underline' | Underline for page sections, pills for compact switches. |
| fullWidth | boolean | false | Stretches tabs to fill the row. |
| @change | (key: string) => void | — | Fires only when the key actually changes. |