Command Palette
A keyboard-first command palette built around a flat items array. ⌘K (Mac) / Ctrl+K (everywhere else) toggles a focus-trapped dialog with a search input, a fuzzy-filtered list, and arrow / Enter / Esc navigation. Owns its own scorer — no cmdk dependency — so the bundle stays small.
If you want a compound, slot-driven API instead, reach for Command Menu.
Customize
State
Content
0
Installation
npx shadcn@latest add https://craftbits.dev/r/command-palette.jsonUsage
Pass an items array — each row is rendered into the list, fuzzy-matched against the query, and dispatches onSelect on click or Enter.
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CommandPalette } from "@craft-bits/core";
export function SiteSearch() {
const router = useRouter();
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Open <kbd>⌘K</kbd>
</button>
<CommandPalette
open={open}
onOpenChange={setOpen}
hotkey
items={[
{ id: "home", group: "Pages", label: "Home", hint: "/", onSelect: () => router.push("/") },
{ id: "docs", group: "Pages", label: "Docs", hint: "/docs", onSelect: () => router.push("/docs") },
{ id: "theme", group: "Actions", label: "Toggle theme", hint: "⌘J", onSelect: toggleTheme },
]}
/>
</>
);
}Uncontrolled — let the palette own its state, useful when the global hotkey is the only trigger:
<CommandPalette items={items} defaultOpen={false} hotkey />Controlled query — pair query + onQueryChange to drive remote search or analytics:
<CommandPalette
items={results}
query={query}
onQueryChange={setQuery}
open={open}
onOpenChange={setOpen}
/>Understanding the component
- Flat data, no slots. A single
itemsarray — each row carriesid,label, optionalhint, optionalicon, optionalkeywords, optionalgroup, andonSelect. Reaching for the data shape rather than compound JSX keeps consumers terse when rows come from a fetch. - Built-in fuzzy filter. A 5-tier scorer (exact, prefix, substring, keyword substring, subsequence) ranks rows live as the user types. No
cmdkdependency — the scorer is ~30 lines so the bundle stays lean. - Grouping preserves first-seen order. Items sharing a
groupcluster under one eyebrow heading. Group order follows the order each heading first appears initems, so consumers control the layout without a separategroupsprop. - Controlled + uncontrolled. Both
openandqueryaccept the Radix value/defaultValue pair. PassdefaultOpen/defaultQueryfor self-managed state, or wire both for external control. - Global hotkey (opt-in). Pass
hotkeyto register awindowkeydown listener that toggles the palette on ⌘K (Mac) / Ctrl+K (other). The handlerpreventDefaults so the browser's Find-in-Page never fights you. Defaults to off — the assumption is the consumer wires the trigger themselves. - Keyboard navigation. Up / Down move between enabled rows, Home / End jump to the ends, Enter selects, Esc closes. Disabled rows are skipped. Mouse-hover sets the active row too, so keyboard and pointer share one source of truth.
- Focus-trap + restore. On open, focus moves into the input on the next frame so motion has time to mount the panel. Tab cycles inside the panel without escaping. On close, focus restores to whatever element opened the palette.
- Motion. Backdrop fades in; panel scale-pops from the top — both with
SPRINGS.snap.AnimatePresence initial={false}skips the first-render animation so apps that mount withdefaultOpendon't get a flash.
Variants
Uncontrolled + global hotkey
<CommandPalette items={items} defaultOpen={false} hotkey />Without the hotkey hint footer
<CommandPalette
items={items}
open={open}
onOpenChange={setOpen}
showHotkeyHint={false}
/>Grouped items
<CommandPalette
items={[
{ id: "home", group: "Pages", label: "Home", hint: "/", onSelect: goHome },
{ id: "docs", group: "Pages", label: "Docs", hint: "/docs", onSelect: goDocs },
{ id: "new", group: "Actions", label: "New file", hint: "⌘N", onSelect: createFile },
]}
open={open}
onOpenChange={setOpen}
/>Props
CommandPalette
| Prop | Type | Default | Description |
|---|---|---|---|
items | CommandPaletteItem[] | — | Items to surface. Filtered live as the user types. |
open | boolean | — | Controlled open state. Pair with onOpenChange. |
defaultOpen | boolean | false | Uncontrolled initial open state. |
onOpenChange | (open: boolean) => void | — | Fired when the palette opens or closes. |
query | string | — | Controlled search query. Pair with onQueryChange. |
defaultQuery | string | "" | Uncontrolled initial query. |
onQueryChange | (query: string) => void | — | Fired when the user types in the search input. |
placeholder | string | "Search…" | Input placeholder. |
label | string | "Command palette" | Accessible name for the dialog. |
emptyMessage | ReactNode | "No results found." | Body for the no-match row. |
hotkey | boolean | false | Register the global ⌘K / Ctrl+K toggle. |
showHotkeyHint | boolean | true | Render the ⌘ + K hint row at the panel footer. |
className | string | — | Merged onto the dialog panel. |
overlayClassName | string | — | Merged onto the backdrop overlay. |
CommandPaletteItem
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Stable identifier — used as the React key and selection token. |
label | string | — | Visible label, also the primary fuzzy-match target. |
hint | ReactNode | — | Right-aligned subtitle (path, kbd, description). |
icon | ReactNode | — | Left-aligned glyph. |
keywords | readonly string[] | — | Extra keywords mixed into the fuzzy score. |
group | string | — | Heading — items sharing a group cluster under one eyebrow. |
disabled | boolean | false | Dim and skip the row in keyboard navigation. |
onSelect | () => void | — | Fires on click or Enter when this is the active row. |
Accessibility
- The dialog panel carries
role="dialog"+aria-modal="true"+ anaria-label(defaults to"Command palette"; override withlabel). A hidden labelled span backsaria-labelledbyso screen readers always announce the dialog name. - The search input uses the WAI-ARIA combobox pattern:
role="combobox",aria-controlspoints at the list,aria-activedescendanttracks the active row's id,aria-autocomplete="list"advertises the inline-completion model. - The list uses
role="listbox"; each row isrole="option"witharia-selectedtoggled on the active row andaria-disabledon disabled rows. - Keyboard: Up / Down move between enabled rows, Home / End jump to the ends, Enter selects, Esc closes. Tab cycles focus inside the panel without escaping. The global ⌘K / Ctrl+K toggle is registered on
windowand removed on unmount. - On open, focus moves into the input on the next frame so motion has time to mount the panel. On close, focus restores to whatever element opened the palette.
- Body scroll is locked while the palette is open so the page underneath can't scroll out from under the dialog.
Credits
- Extracted from:
terminal-dreams(src/components/CommandPalette/CommandPalette.tsx). The original wrapped Radix Dialog +cmdkand hard-coded the blog'sPages/Principles/Frontend Design/Postsgroups; craft-bits drops the project-specific data, removes thecmdkdependency by inlining a small fuzzy scorer, and generalises the API to a flatitemsarray with optionalgroup,hint,icon, andkeywordsper row. - Inspired by: cmdk — the reference command-palette implementation.