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.

Actions

Navigate

Show 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, and closedby="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.

Actions

Files

People

Show 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 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 empty slot) 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, and closedby="any" backdrop-click-to-close for free.
  • role="combobox" input wired with aria-expanded, aria-controls, aria-autocomplete="list"; results are role="option" with aria-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 commands reasonably sized — filtering is a client-side substring match against label + keywords, so very large lists get sluggish.
  • Do use keywords for synonyms a user might type instead of the exact label.
  • Don’t mount two palettes with the same shortcut on 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.
  • placeholder states what can be typed; the default (“Type a command or search…”) works for most cases.