CommandPalette
A ⌘K / Ctrl+K searchable command menu in a native dialog.
Last updated
Uses JavaScript
Opens on ⌘K / Ctrl+K (or a configurable shortcut), or from any element
with data-command-open="<id>". Typing filters commands by label and
keywords; arrow keys move the highlight, Enter activates it — navigating to
href, or dispatching an apogee:command event on document. Commands
with a shortcut run from anywhere on the page, and key caps read Ctrl
instead of ⌘ outside macOS.
Without JS: There is no fallback — the dialog and shortcut don't run at all. Always pair a trigger with a real, independently reachable link or menu.
Show codeHide code
<button type="button" data-command-open="apogee-command" class="rounded-md border border-neutral px-3 py-2 text-sm">Open (⌘K)</button>
<CommandPalette
commands={[
{ label: 'Create project', group: 'Actions', keywords: 'new add' },
{ label: 'Settings', href: '/settings', group: 'Navigate' },
{ label: 'Documentation', href: '/docs', group: 'Navigate' },
]}
/>Add
npx @ava-ui/apogee add command-palette
No Apogee dependencies. Uses @lucide/astro internally (Search) for the
input icon — no icon import needed in your usage.
Usage
---
import CommandPalette from '@/components/apogee/CommandPalette/CommandPalette.astro'
---
<button type="button" data-command-open="apogee-command">Search…</button>
<CommandPalette
commands={[
{ label: 'Create project', group: 'Actions' },
{ label: 'Settings', href: '/settings', group: 'Navigate' },
]}
/>
Anatomy
- Dialog — native
<dialog>; modal, backdrop, andclosedby="any"come free. - Search input —
role="combobox", filters commands as you type. - Groups — commands grouped by
group, in first-seen order; ungrouped commands render without a heading. - Options —
role="option"buttons, one per command; an empty-state message shows when nothing matches.
API
@composable — exposed to Content Editors in AVA. No passthrough attrs — the
dialog doesn’t spread ...rest.
| Prop | Type | Default | Description |
|---|---|---|---|
id |
string |
'apogee-command' |
Dialog id; also the target for data-command-open="<id>" triggers. |
commands |
{ label; href?; group?; keywords?; id?; icon?; avatar?; description?; shortcut?; stacked? }[] |
— | The command list. Required. icon is a Lucide component; avatar is { src?, name? }; description shows after the label, or under it with stacked; shortcut (e.g. ['alt', 'shift', 'n']) renders key caps and runs the command from anywhere on the page. |
placeholder |
string |
'Type a command or search…' |
Search input placeholder. |
shortcut |
string |
'k' |
Key pressed with ⌘ / Ctrl to open. |
src |
string |
— | URL of a JSON array of more commands ({ label, href?, group?, keywords?, description? }), fetched the first time the palette opens and added after commands. |
class |
string |
— | Extra classes on the <dialog>. |
Slots: empty replaces “No results.” (an EmptyState fits); footer is a row of
key hints under the list, shown from md.
Rich commands
Icons, avatars, descriptions and shortcuts. Try typing “resume” (accents are ignored) or press ⌘⇧P / Ctrl+Shift+P anywhere on this page.
Show codeHide code
<CommandPalette id="rich-command" commands={[
{ label: 'New project', id: 'new-project', group: 'Actions', icon: FolderPlus, shortcut: ['alt', 'shift', 'n'] },
{ label: 'Résumé', group: 'Files', icon: FileText, description: 'Updated yesterday' },
{ label: 'Ana Lima', group: 'People', avatar: { name: 'Ana Lima' }, description: 'Product designer', stacked: true },
]}>
<EmptyState slot="empty" variant="plain" size="small" title="Nothing matches"><SearchX slot="icon" /></EmptyState>
<Fragment slot="footer"><span><Kbd>↑</Kbd> <Kbd>↓</Kbd> to move</span><span><Kbd>↵</Kbd> to select</span><span><Kbd>esc</Kbd> to close</span></Fragment>
</CommandPalette>Long lists
A palette with hundreds of commands makes every page heavy. Pass src instead:
the palette fetches the JSON the first time it opens and builds those commands
then. This page’s palette loads all of these docs from /search.json. Commands
from src take a label, link, group, keywords and description; icons, avatars
and shortcuts stay with commands.
Show codeHide code
<CommandPalette id="docs-command" commands={[]} src="/search.json" />States
- Closed — default; the dialog isn’t rendered visibly until opened.
- Open — backdrop + dialog fade in (
@starting-style). - Highlighted option — the active result (
aria-selected), moved with arrow keys or reset to the first result on every filter. - Empty — “No results.” (or the
emptyslot) shows when nothing matches the query; empty groups hide their heading.
Examples
Grouped commands with keywords (aliases the label doesn’t contain):
<CommandPalette
commands={[
{ label: 'Create project', group: 'Actions', keywords: 'new add' },
{ label: 'Invite teammate', group: 'Actions', keywords: 'member user' },
{ label: 'Settings', href: '/settings', group: 'Navigate' },
]}
/>
A command with no href dispatches apogee:command for the app to handle:
---
// listen on the client:
// document.addEventListener('apogee:command', (e) => { ... e.detail.id })
---
<CommandPalette
id="app-command"
commands={[{ label: 'Toggle theme', id: 'toggle-theme' }]}
/>
Custom shortcut key:
<CommandPalette shortcut="/" commands={[{ label: 'Search docs', href: '/docs' }]} />
Accessibility
| Key | Action |
|---|---|
⌘/Ctrl + shortcut |
Opens the palette (default K). |
↓ ↑ |
Move the highlight; stops at the first and last result. |
Enter |
Runs the highlighted command. |
Esc |
Closes the palette. |
Command shortcut |
Runs that command from anywhere on the page, except while typing in a field. |
- Native
<dialog>gives a focus trap,Esc-to-close, andclosedby="any"backdrop-click-to-close for free. role="combobox"input wired witharia-expanded,aria-controls,aria-autocomplete="list"; results arerole="option"witharia-selected.- No JS: there is no fallback — the dialog, shortcut, and filtering are
entirely JS-driven with no
no-js:variant. Always give commands a real, independently reachable trigger (a visible link or menu item) alongside the palette.
Tokens
| Token | Used for |
|---|---|
--border-neutral |
Dialog border, header divider |
--background-page |
Dialog background |
--foreground-neutral |
Option text |
--foreground-neutral-faded |
Search icon, placeholder, empty state |
--foreground-neutral |
Group heading |
--border-neutral |
Rule between groups |
--background-neutral-faded-highlighted |
Option hover / selected background |
--duration-fast / --ease-standard |
Open/close transition |
Do / Don’t
- Do keep
commandsreasonably sized — filtering is a client-side substring match against label + keywords, so very large lists get sluggish. - Do use
keywordsfor synonyms a user might type instead of the exact label. - Don’t mount two palettes with the same
shortcuton one page — the first keydown listener wired wins. - Don’t treat the palette as the only way to reach a command — always pair it with a visible trigger.
Content
- Command labels are short: verb-led when they act (“Create project”), noun-led when they navigate (“Settings”).
- Group names are short category nouns, sentence case.
placeholderstates what can be typed; the default (“Type a command or search…”) works for most cases.