CommandPalette
⌘K to go anywhere.
Why it exists
Seed, Branch and Harvest each grew one, and so did these docs. Seed's and Branch's docstrings are nearly word for word — “the thing somebody wants is almost always one they can already name, and every other route to it costs a page load and a scroll”, and “deliberately also a navigator: half of what people want is take me to the review queue, and a palette that only searches gets used once.” Both are true, and both are now here.
Three of the four were not modals
They set role="dialog" aria-modal="true" on a fixed div, which announces a modal and is not one.
Tab walked straight out into the page behind
inert, and Escape was bound to the input's onKeyDown — so once somebody had moved focus to a result row with the mouse, Escape did nothing and the only way out was clicking the backdrop. This one is a native <dialog>, for the same reason Azalea's Dialog is: showModal() gives focus trapping, Escape, inert and a ::backdrop for free, and a hand-rolled version gets them wrong more often than right.Try it
Static pages filter locally from the first keystroke; runs come from a deliberately slow, deliberately uneven fake server. Type fast — an earlier request will return after a later one, and you will not see it.
Keyboard-only
How all three apps render theirs: no button anywhere, just the key. ⌘K toggles, and a bare / opens it — but never while somebody is typing into a field, which is the omission that makes a single-key shortcut infuriating rather than useful.
The reply you already typed past
Five places in the suite search while somebody types, and every one wrote a paragraph about the same bug. They fixed it two ways; the generation counter is the one kept, because it does not depend on a cleanup running.
The debounce is a parameter rather than a constant, because the copies chose different delays and both were right: 180ms in a palette, where the answer should feel instant, and 500ms under a title field — “short enough that the answer arrives before somebody has moved on to the description”.
One thing none of the four did
Scroll the highlighted row into view. With twenty results, arrowing down moves a highlight you cannot see — the list stays where it was and the selection walks off the bottom of it.
block: "nearest" rather than "center": it moves the list only when it has to, so arrowing through the visible rows does not scroll under the cursor on every press.
Guidance
Use it when
- Any app past about fifteen pages. Below that the rail is faster than a key.
- Both halves — the app's pages as items, and its records through search. A palette that only searches gets used once.
- In AppShell's palette slot, which puts the button beside the theme toggle in the rail and in the mobile header.
Reach for something else when
- A router import inside Azalea. onNavigate takes the app's own, for the same reason AppShell takes its nav as a node.
- Returning everything from search. A list long enough to scroll means the person wanted the real search page.
- Making it the only way to reach something. It needs a keyboard, and there is none on a phone.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | CommandItem[] | — | Already on the client. Filtered and ranked locally: label prefix beats label contains beats hint. |
| search | (query: string) => Promise<CommandItem[]> | — | Anything needing the server. Debounced; a stale reply is discarded. |
| onNavigate | (href: string) => void | — | Where an item's href goes. Next: useRouter().push. |
| trigger | boolean | true | The button. false for keyboard-only. |
| minLength | number | 2 | Characters before search runs. Local items filter from the first. |
| delay | number | 180 | Debounce before search runs. |
| slashOpens | boolean | true | Also open on a bare “/”, except while typing into a field. |
| label | string | — | Names the dialog and the trigger. Say what can be found. |
CommandItem
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | Unique within the palette. The React key. |
| label | string | — | Matched against, and shown unless render is given. |
| hint | string | — | A second field to match, shown beside the label. |
| href | string | — | Passed to onNavigate on select. |
| onSelect | () => void | — | Done instead of navigating. Wins over href. |
| group | string | — | A heading above this item's run of the list. |
| render | ReactNode | — | Full control of the row. The palette keeps the highlight and the key handling. |
Accessibility
The dialog is the browser's, so focus trapping, Escape and inert on the rest of the page are too — and Escape works from anywhere inside it rather than only from the input. Group headings are role="presentation", so they are not announced as options and the list count stays right. Rows are min-h-10 on touch. The trigger is icon-only below sm and carries an explicit label; the shortcut hint is hidden there, because there is no key to press.