Command palette
StableOne search field over every page and action, opened with ⌘K, grouped and ranked, run with Enter.
A native modal <dialog> holding a combobox and a listbox. Focus stays in the field while the arrow keys move the highlight, Enter runs the command, and the dialog closes before the command runs. Results are grouped and ranked by a simple, predictable score. The site search you open with ⌘K is this component.
Sample commands
import { useMemo, useState } from 'react'import { Button, CommandPalette, useToast, type CommandItem } from '@rchursin/product/ui'
/** * Open it, type, then ↑ ↓ and Enter. Before you type it shows the suggested commands; typing * searches all of them. These commands show a toast instead of doing the real thing. */export default function CommandPaletteBasic() { const [open, setOpen] = useState(false) const toast = useToast()
const items = useMemo<CommandItem[]>(() => { const demo = (title: string) => () => toast({ title, description: 'Demo command: nothing changed.' }) return [ { id: 'go-projects', group: 'Go to', label: 'Projects', icon: 'folder', hint: 'G then P', onSelect: demo('Opened Projects') }, { id: 'go-inbox', group: 'Go to', label: 'Inbox', icon: 'inbox', hint: 'G then I', onSelect: demo('Opened Inbox') }, { id: 'go-calendar', group: 'Go to', label: 'Calendar', icon: 'calendar', keywords: ['schedule', 'events'], onSelect: demo('Opened Calendar') }, { id: 'new-task', group: 'Create', label: 'New task', icon: 'plus', hint: 'C', keywords: ['add', 'todo', 'issue'], onSelect: demo('Task created') }, { id: 'new-note', group: 'Create', label: 'New note', icon: 'note', keywords: ['write', 'memo'], onSelect: demo('Note created') }, { id: 'new-event', group: 'Create', label: 'New event', icon: 'calendar-plus', keywords: ['meeting', 'schedule'], onSelect: demo('Event created') },Usage
import { CommandPalette, useCommandShortcut, type CommandItem } from '@rchursin/product/ui'const [open, setOpen] = useState(false)useCommandShortcut(() => setOpen(true)) // ⌘K / Ctrl+K, and "/" outside fields
<CommandPalette open={open} onClose={() => setOpen(false)} items={items} label="Search the site" />Examples
How results rank
scoreItem() on its own: type a query and watch the order and scores change.
import { useState } from 'react'import { Field, TextInput, scoreItem } from '@rchursin/product/ui'
/** * scoreItem() is how the palette ranks: exact 100 › starts with 80 › a word starts with 60 › * contains 40 › letters in order 15, times 1 for the label, 0.7 for a keyword, 0.4 for the hint. * Ties keep your order; 0 means hidden. */const items = [ { label: 'New task', keywords: ['add', 'todo'] }, { label: 'Task board', hint: 'View' }, { label: 'Archive finished tasks', keywords: ['clean up'] }, { label: 'Settings', keywords: ['preferences'] }, { label: 'Switch theme', keywords: ['dark mode'] }, { label: 'Start a focus session', hint: 'Timer' },]
export default function CommandScoring() { const [query, setQuery] = useState('ta') const ranked = items.map((item, i) => ({ item, i, score: scoreItem(item, query) })).sort((a, b) => b.score - a.score || a.i - b.i)
return ( <div className="flex w-full max-w-md flex-col gap-4"> <Field label="Query" hint="Try “task”, “tb”, “dark” or “timer”."> {(p) => <TextInput {...p} value={query} onChange={(e) => setQuery(e.target.value)} autoComplete="off" spellCheck={false} />} </Field> <ol aria-label="Items by score" className="flex flex-col gap-1.5"> {ranked.map(({ item, score }) => ( <li key={item.label} className={score ? 'flex items-center gap-3 text-[13px]' : 'flex items-center gap-3 text-[13px] text-fg-3 line-through'}> <span className="min-w-0 flex-1 truncate">{item.label}</span> <span aria-hidden="true" className="h-1.5 w-20 shrink-0 overflow-hidden rounded-full bg-fg/[0.08]"> <span className="block h-full rounded-full bg-fg" style={{ width: `${Math.min(100, score)}%` }} /> </span> <span className="w-8 shrink-0 text-right font-mono text-[12px] tabular-nums">{Math.round(score)}</span> </li> ))} </ol> </div> )}API
Command palette props
| Prop | Type | Default | Description |
|---|---|---|---|
openrequired | boolean | – | Whether the palette is showing. Opening clears the query and focuses the field. |
onCloserequired | () => void | – | Called on Esc, a click outside the panel, the Esc button, or before a command runs. |
itemsrequired | CommandItem[] | – | Everything that can be found. See CommandItem below. |
initialItems | CommandItem[] | items | What shows before anything is typed, e.g. pages and recent actions. |
label | string | 'Search' | Accessible name of the dialog and the field, e.g. “Search the site”. |
placeholder | string | 'Search…' | Placeholder in the field. |
limitPerGroup | number | 8 | Most results shown per group while searching. |
footer | ReactNode | – | Quiet text at the right of the footer, e.g. “71 components”. |
CommandItem
| Prop | Type | Default | Description |
|---|---|---|---|
idrequired | string | – | Unique; used for the option’s id. |
labelrequired | string | – | What the row says, and the strongest thing to match. |
grouprequired | string | – | Heading it is listed under. Groups appear in the order of their best result. |
hint | string | – | Quiet trailing text (a category, a shortcut). Matched weakly. |
icon | IconName | – | Icon in a small square before the label. |
keywords | string[] | – | Other words that should find it (“dark mode” for Switch theme). |
onSelectrequired | () => void | – | Runs on the next frame after the palette closes, so focus is back on the opener first. |
useCommandShortcut(openPalette, enabled?)
| Prop | Type | Default | Description |
|---|---|---|---|
openPaletterequired | () => void | – | Called on ⌘K (Ctrl+K elsewhere), even while typing in a field, and on “/” when not typing. These are page-wide: a tool’s own scoped “/” (useHotkeys with scope) wins while focus is inside it. |
enabled | boolean | true | Turn the shortcuts off, e.g. while another palette owns them. |
scoreItem(item, query)
| Prop | Type | Default | Description |
|---|---|---|---|
itemrequired | Pick<CommandItem, 'label' | 'keywords' | 'hint'> | – | What to score. |
queryrequired | string | – | What was typed; trimmed and lower-cased. |
returns | number | – | Exact 100, starts with 80, a word starts with 60, contains 40, letters in order 15; × 1 for the label, 0.7 for keywords, 0.4 for the hint. 1 for an empty query, 0 for no match. |
CSS
| Class or variable | What it does |
|---|---|
rc-command | Places the palette high on the screen (min(12vh, 7rem) from the top) instead of dead centre. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| CtrlK/ | Opens the palette, with useCommandShortcut. “/” only works when not typing in a field. |
| Down arrowUp arrow | Moves the highlight, wrapping at the ends. Focus stays in the field. |
| Page DownPage Up | Moves the highlight eight rows at a time. |
| CtrlHomeCtrlEnd | Jumps to the first or last result. Home and End alone still move the text cursor. |
| Enter | Closes the palette and runs the highlighted command. |
| Esc | Closes it; focus goes back to whatever opened it. |
- It is a native modal <dialog> opened with showModal(): the page behind is inert, so Tab only reaches the field and the Esc button (then the browser’s own controls), and closing returns focus to the opener.
- The field is a combobox (aria-expanded, aria-controls, aria-autocomplete="list") pointing at the highlighted option with aria-activedescendant, so focus never leaves the field.
- Results are a listbox with one group per heading; the highlighted option has aria-selected="true" and a return-key icon, not just a tint.
- The number of results (“4 results”, “No results”) is announced in a polite status region as you type.
- The Esc key cap in the field is also a button named “Close search”.
Guidelines
Do
- Start command labels with a verb (“New task”, “Switch theme”) and pages with their name.
- Add keywords for the words people actually type.
- Show a few useful items before anything is typed with initialItems.
Don’t
- Don’t make the palette the only way to reach something.
- Don’t bind ⌘K twice on one page; pass enabled={false} to the second useCommandShortcut.
In use
Used on this site
Related
- DialogA modal on the native <dialog> element: the page behind goes inert, Esc closes it, and focus goes back to what opened it.
- useHotkeysSingle-key and modifier shortcuts, Linear style, that wait while you type and can be scoped to one part of the page.
- Key capsKeyboard keys cut from the same metal as the buttons, and shortcuts written as key caps.
- Link & routerA small router with no dependencies: Link changes page without a reload, useRouter says where you are, in history or hash mode.
Source
The component’s own files, exactly as they run on this site.
/** * Command palette (⌘K): one search field over everything, results grouped, keyboard-first. * A native <dialog> (focus trapped by the browser, Esc closes, focus returns to the opener) * holding a combobox + listbox (WAI-ARIA APG): focus stays in the field, ↑ ↓ move the highlight * through aria-activedescendant, Enter runs it, Home / End jump, the result count is announced. * * const [open, setOpen] = useState(false) * useCommandShortcut(() => setOpen(true)) // ⌘K / Ctrl+K, and "/" outside fields * <CommandPalette open={open} onClose={() => setOpen(false)} items={items} /> * * Scoring is simple and predictable: exact > starts with > a word starts with > contains > * letters in order; ties keep your order. Empty query shows `initialItems` (or every item). */import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent,