Azalea

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.

Copies before
4
three apps and this site
Lines between them
~640
of the same machinery
With a real focus trap
1
only this site's

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

The background was never 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.

Try “inv”, or ⌘K, or / — and type fast.

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.

No button on the page. Press ⌘K or / — this one is reachable only by key.

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

PropTypeDefaultDescription
itemsCommandItem[]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) => voidWhere an item's href goes. Next: useRouter().push.
triggerbooleantrueThe button. false for keyboard-only.
minLengthnumber2Characters before search runs. Local items filter from the first.
delaynumber180Debounce before search runs.
slashOpensbooleantrueAlso open on a bare “/”, except while typing into a field.
labelstringNames the dialog and the trigger. Say what can be found.

CommandItem

PropTypeDefaultDescription
idstringUnique within the palette. The React key.
labelstringMatched against, and shown unless render is given.
hintstringA second field to match, shown beside the label.
hrefstringPassed to onNavigate on select.
onSelect() => voidDone instead of navigating. Wins over href.
groupstringA heading above this item's run of the list.
renderReactNodeFull 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.