Appearance
Command Palette — the cross-framework headless command menu
CommandPalette is Rozie's headless, accessible cmdk-style command menu — a @rozie-ui family with no third-party engine behind it. The "⌘K" pattern (a centered modal overlay with a search box over a filtered, keyboard-navigable list of commands) is re-implemented — often inaccessibly — in every framework. Rozie owns the author-side API: the two-way open + query bindings, fuzzy ranking with match highlighting over each item's label plus its keywords (with a pluggable score hook), nested levels for drill-in navigation backed by optional async sources, auto-derived group sections (from each item's group field, cappable via groupCap), per-row action menus (⌘K on a highlighted row), a defaultItems home view while the query is empty, the roving-highlight keyboard model (ArrowUp / ArrowDown / Home / End / Enter / Escape), the close policy (backdrop click + Escape), and the token-themed skin.
The same CommandPalette ships for React, Vue, Svelte, Angular, Solid, and Lit. And because every visual value is a CSS custom property, it re-skins to any design system — with ready-made bridges for shadcn/ui, Material 3, and Bootstrap 5.
The @rozie-ui/command-palette packages
CommandPalette ships as six pre-compiled, per-framework packages; install only the one for your framework. There is no build step and no Rozie toolchain to add:
| Package | Install | README |
|---|---|---|
@rozie-ui/command-palette-react | npm i @rozie-ui/command-palette-react | react/README |
@rozie-ui/command-palette-vue | npm i @rozie-ui/command-palette-vue | vue/README |
@rozie-ui/command-palette-svelte | npm i @rozie-ui/command-palette-svelte | svelte/README |
@rozie-ui/command-palette-angular | npm i @rozie-ui/command-palette-angular | angular/README |
@rozie-ui/command-palette-solid | npm i @rozie-ui/command-palette-solid | solid/README |
@rozie-ui/command-palette-lit | npm i @rozie-ui/command-palette-lit | lit/README |
Each package carries its framework peer plus @rozie-ui/combobox-<target> as a required peer (the palette's search-over-list core composes the combobox family). Install both:
bash
npm i @rozie-ui/command-palette-react @rozie-ui/combobox-reactFor the full prop / event / handle / slot surface see the API reference; for per-framework consumption code see the usage page.
Quick start
Two-way bind open (visibility) and query (the search text), hand the component an items array, and run the chosen command in @select. The component owns the overlay, the search input, the filter, and the keyboard navigation:
rozie
<components>
{
CommandPalette: './CommandPalette.rozie',
}
</components>
<data>
{
paletteOpen: false,
q: '',
}
</data>
<script>
const commands = [
{ id: 'new', label: 'New File', group: 'File', keywords: ['create', 'add'] },
{ id: 'open', label: 'Open File', group: 'File' },
{ id: 'settings', label: 'Preferences', group: 'App' },
]
const run = (e) => {
console.log('ran command:', e.item.id)
}
</script>
<template>
<button @click="$data.paletteOpen = true">Open palette (⌘K)</button>
<CommandPalette
r-model:open="$data.paletteOpen"
r-model:query="$data.q"
:items="commands"
@select="run"
/>
</template>How it works
- Two models, not a form control.
openandqueryare bothmodel: true. Because there are two models the component does not generate an AngularControlValueAccessor(a palette is not a single form value) — that is the intended shape. - Portal-style overlay. The overlay is a
position: fixedfull-viewport backdrop + a centeredrole="dialog"panel, rendered only whileopen. It escapes overflow/z-indexancestors without a teleport. A click on the backdrop (not the panel) closes; Escape closes; selecting an item closes whencloseOnSelect(the default). - Fuzzy ranking + highlighting. The query is matched as a fuzzy subsequence against each item's
label(weighted above itskeywords), results are ranked by match strength, and the matched characters are highlighted in every row (themeable via--rozie-command-palette-match-*). Pass ascoreprop —(item, query) => number | null— to customize ranking or exclusion (returnnullto drop an item; higher numbers rank first); a recency/frecency boost is simply added inside it. The ranking lives insrc/internal/scoreCommands.tsand is unit-tested in isolation. - Roving keyboard model. ArrowUp / ArrowDown move the highlight (skipping
disableditems), Home / End jump to the ends, Enter selects the highlighted item, Escape closes (or pops a level — see below). The highlight is tracked virtually viaaria-activedescendant— DOM focus stays on the search<input role="combobox">. - Grouped sections & the home view. Commands sharing a
groupfield render as labeled sections automatically (first-appearance order, headings overridable via thegroupHeadingslot, cappable per-section withgroupCap+ a "+N more" row); thedefaultItemsprop (and a level's owndefaultItemsfield) is what renders while the query is empty — the natural home for a recents list. See Grouped commands and Default items. - Per-row action menus. A row carrying
actions: [{ id, label, … }]gets its own secondary action menu — opened withactionKey(default ⌘K), caret-at-end →, or a click on the row's actions affordance — firing@action-selectwhile Enter stays the primary@select. See Interactive sub-actions. - Scoped slots. Row-level:
option(custom row render, scoped{ option, index, active, selected, disabled, matches }— the listbox vocabulary shared with@rozie-ui/listbox), plus three additive display slots on the default row —icon(leading, scoped{ option }),actions(trailing action hints, scoped{ option, actions }), andtrailing(right edge, scoped{ option }). Section/menu-level:groupHeading{ group }andactionItem{ action, item, active, disabled }. State-level:empty{ query },loading{ query }(async in flight),error{ query, error, retry }(async rejected),breadcrumb{ stack, back }(the depth->0 header), andfooter(a persistent footer bar). See the API reference for the full slot table.
Nested levels & async sources
Turn any item into a drill-in level by giving it a children array or a source function — selecting it pushes a new level instead of firing @select. Presence of either field is the navigation signal; there is no separate flag. A source may be async (return a Promise): the level shows the loading slot while it resolves, only the latest in-flight request is applied (stale results are dropped), and keystroke refetches are debounced by searchDebounce (default ~150ms). A rejected source shows the error slot with a retry.
Backspace on an empty query pops one level; Escape pops one level and only closes the palette at the root; a breadcrumb/back header renders at depth > 0. The imperative openTo(path) handle deep-links straight to a nested level (e.g. bind ⌘P to jump to "Go to page…").
rozie
<script>
const commands = [
{ id: 'new', label: 'New File', keywords: ['create'] },
// Static drill-in: `children` makes this a level.
{
id: 'go-page', label: 'Go to page…', title: 'Pages', placeholder: 'Search pages…',
children: [
{ id: 'p-home', label: 'Home' },
{ id: 'p-docs', label: 'Docs' },
],
},
// Async drill-in: `source(query)` fetches on demand; empty query → default view.
{
id: 'go-module', label: 'Go to module…', title: 'Modules', placeholder: 'Search modules…',
source: async (query) => {
const res = await fetch(`/api/modules?q=${encodeURIComponent(query)}`)
return res.json() // [{ id, label }]
},
},
]
const run = (e) => console.log('ran leaf command:', e.item.id, 'via path', e.path)
</script>
<template>
<CommandPalette
ref="palette"
r-model:open="$data.paletteOpen"
r-model:query="$data.q"
:items="commands"
:search-debounce="150"
@select="run"
@navigate="e => console.log('pushed level', e.depth)"
>
<template #loading="{ query }">Searching “{{ query }}”…</template>
<template #error="{ error, retry }">
<button @click="retry()">Failed — retry</button>
</template>
</CommandPalette>
<!-- Deep-link ⌘P straight into the pages level -->
<button @click="$refs.palette.openTo(['go-page'])">Go to page… (⌘P)</button>
</template>Levels compose above the rest of the pipeline: each level's items still flow through the fuzzy score ranking, the combobox groups, and the row slots. For the complete surface — every prop, event, handle, and slot with scopes — see the API reference.
Accessibility
The overlay panel is role="dialog" aria-modal="true" with an accessible name (ariaLabel). The search field is <input role="combobox" aria-autocomplete="list" aria-expanded aria-controls aria-activedescendant>; the results are a role="listbox" of role="option" elements, each with aria-selected (the active option) and aria-disabled. Selection is committed on Enter, and the active option is announced via aria-activedescendant while focus never leaves the input — the WAI-ARIA APG combobox-with-listbox pattern.
See the comparison page for how this replaces the per-framework command-menu libraries.