Command palette

A dialog with a field and a list of commands, ⌘K from anywhere: this site’s own search, graduated into the package. The Dialog holds it, the Input is the field, and the rows are commands the caller names.

Try it

Nothing chosen yet.

Or press ⌘J anywhere on this page — J, because ⌘K is the site’s own search in the rail, the same component, and two palettes must not answer one key. The commands act on the site: go to a component, switch the theme, copy the install command. One is disabled, to show the arrows skipping it.

Commands

AccordionComponents
AlertComponents
Avatar and LoaderComponents
BadgeComponents
BreadcrumbComponents
ButtonComponents
CardComponents
Input and TextareaComponents
Switch to dark⇧D
Switch to light⇧L
surface/raisedCards, panels, table bodyColour
interactive/accentPrimary buttons, linksColour
Copy the install commandnpm install alpenglow
Keyboard shortcutsNot built on this site

What is the caller’s

The palette narrows, moves and reports; everything a command does is the caller’s. It never closes itself: onSelect hands over the item and the caller sets open to false, or opens what comes next in its place. Recent items are the caller’s too, a group like any other; the package writes nothing to storage. A shortcut on an item is drawn as a key cap and binds nothing: the site’s search binds ⌘K through the hook and shows it on the button that opens the palette, and that is the whole of the arrangement.

Searching elsewhere

By default a row stays when its label or one of its keywords holds what was typed, anywhere in it, whatever the case or the accents. A caller with an index — this site’s, with its tolerance for a typo and its excerpts — passes filter={null} and query with onQueryChange, hands back items already narrowed, and says loading while they come. What was typed is marked in Semibold where it occurs in the label and the detail; a fuzzy hit the caller found is not marked, since the palette does not know where it matched.

The keyboard

Keys
KeysDoes
⌘K, Ctrl KOpens, from anywhere, through the hook. The button that opens it says which.
TypingNarrows the rows. A new query starts with no row active.
↓ ↑Move the active row, wrapping, over a disabled one.
Home, EndThe caret’s, until a row is active; then the first and the last row.
EnterFollows the active row, or the first that can be chosen. Not the Enter of an IME composition.
EscCloses, and reaches nothing else on the page.

States

loadingnothing matchesa disabled rowa status the caller writes

Loading and nothing-matches are read in a role="status" line under the field. A caller can put its own line there — “The index did not load.” — while still showing what it has, which is what the site does with its suggestions.

Measures

Measures
PartValue
Dialogthe Dialog, md (640), held at 12vh from the top
Fieldthe Input, lg
Listat most 50vh, then scrolls; the active row scrolled into view
Row8 / 12 padding, radius 8
Label14 / 22
Description, detail, group, key cap12 / 16
Activeinteractive/selected; hovered, the wash
The markSemibold, no fill

The dialog is held near the top rather than centred, so the list grows downward as the results change and the box does not jump. A row is the DropdownMenu’s row at radius lg: the wash on hover, interactive/selected when the arrows reach it, and the mark of what was typed is weight rather than a fill, so those two are the only fills a row takes.

Accessibility

One combobox: the field has role="combobox" with aria-autocomplete="list" and controls the listbox, whose rows are options grouped under named groups; the active row is aria-activedescendant and focus never leaves the field, a press on a row keeping it there. The dialog, the field and the list are all named by label. A disabled row says so and is neither reached by the arrows nor chosen. Modal, the top layer, the inert page behind, Esc and focus return are the platform’s through the Dialog.

Not built

Nested pages inside the palette, a command that opens a sub-list. A non-modal inline palette. Fuzzy scoring in the package. A registry of commands or bindings: the palette shows a shortcut and does not install one.

Using it

TSX
import { CommandPalette, useCommandPaletteShortcut } from 'alpenglow';
import type { CommandGroup, CommandItem } from 'alpenglow';

const [open, setOpen] = useState(false);
const hint = useCommandPaletteShortcut(() => setOpen(true)); // "⌘K" or "Ctrl K"

const items: CommandGroup[] = [
  { label: 'Go to', items: [{ id: 'colour', label: 'Colour', description: 'Foundations' }] },
  { label: 'Actions', items: [{ id: 'dark', label: 'Switch to dark', shortcut: '⇧D' }] },
];

<button aria-keyshortcuts="Meta+K Control+K" title={hint} onClick={() => setOpen(true)}>Search</button>

<CommandPalette
  open={open}
  onClose={() => setOpen(false)}
  label="Commands"
  placeholder="Type a command"
  items={items}
  onSelect={(item: CommandItem) => {
    setOpen(false);
    run(item.id);
  }}
/>

// A caller that searches elsewhere hands the rows already narrowed:
<CommandPalette filter={null} query={query} onQueryChange={setQuery} loading={pending} items={results} … />
CommandPalette props
PropTypeDefault
openbooleanrequired
onClose() => voidrequired
labelstring — the dialog, the field and the list are named by itrequired
itemsreadonly CommandGroup[] — { label, items: CommandItem[] }required
onSelect(item: CommandItem) => voidrequired
placeholderstring—
iconReactNode — at the start of the field—
filter((item, query) => boolean) | nulllabel or a keyword holds the query
query / defaultQuery / onQueryChangestring / string / (query: string) => void— / '' / —
loading / loadingTextboolean / stringfalse / 'Loading…'
emptyTextstring | ((query: string) => string)Nothing matches “…”.
statusReactNode — a line the caller writes, in place of the palette’s own—
size'xs' | 'sm' | 'md' | 'lg''md'
classNamestring—
CommandItem
PropTypeDefault
idstringrequired
labelstring — the row’s name; what the default filter readsrequired
descriptionstring — at the end of the first line—
detailstring — a second line—
iconReactNode—
shortcutstring — drawn as a key cap; nothing is bound—
keywordsreadonly string[] — searched, never shown—
disabledbooleanfalse
monoboolean — the label is codefalse