Skip to main content

Search Command

The ⌘K palette in one piece. A trigger that looks like an input, a dialog with search, grouped results, keyboard navigation and a hint row, wired to a single onSelect.

Press ⌘K or click the field

1'use client';
2
3import { useState } from 'react';
4import { SearchCommand, type SearchItem } from '@/components/ui/search-command';
5
6function Glyph({ d }: { d: string }) {
7 return (
8 <svg
9 xmlns="http://www.w3.org/2000/svg"
10 width="16"
11 height="16"
12 viewBox="0 0 24 24"
13 fill="none"
14 stroke="currentColor"
15 strokeWidth="1.5"
16 strokeLinecap="round"
17 strokeLinejoin="round"
18 aria-hidden="true"
19 >
20 <path d={d} />
21 </svg>
22 );
23}
24
25const ICONS = {
26 home: 'M3 11 12 3l9 8v10H3z',
27 file: 'M6 3h8l4 4v14H6z M14 3v4h4',
28 box: 'M4 7l8-4 8 4v10l-8 4-8-4z M4 7l8 4 8-4 M12 11v10',
29 moon: 'M20 14A8 8 0 1 1 10 4a6 6 0 0 0 10 10z',
30 user: 'M20 21a8 8 0 0 0-16 0 M12 13a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
31};
32
33export function Default() {
34 const [last, setLast] = useState<string | null>(null);
35
36 const items: SearchItem[] = [
37 {
38 id: 'home',
39 label: 'Home',
40 group: 'Pages',
41 icon: <Glyph d={ICONS.home} />,
42 shortcut: ['G', 'H'],
43 },
44 {
45 id: 'docs',
46 label: 'Documentation',
47 group: 'Pages',
48 icon: <Glyph d={ICONS.file} />,
49 shortcut: ['G', 'D'],
50 },
51 {
52 id: 'icons',
53 label: 'Icons',
54 group: 'Pages',
55 icon: <Glyph d={ICONS.box} />,
56 keywords: ['svg', 'glyph'],
57 },
58 {
59 id: 'button',
60 label: 'Button',
61 group: 'Components',
62 description: 'Actions, in five variants',
63 icon: <Glyph d={ICONS.box} />,
64 },
65 {
66 id: 'dialog',
67 label: 'Dialog',
68 group: 'Components',
69 description: 'A modal with focus trapped',
70 icon: <Glyph d={ICONS.box} />,
71 },
72 {
73 id: 'chat',
74 label: 'Chat',
75 group: 'Components',
76 description: 'The whole thread in one piece',
77 icon: <Glyph d={ICONS.box} />,
78 keywords: ['ai', 'hybrid'],
79 },
80 {
81 id: 'theme',
82 label: 'Toggle theme',
83 group: 'Actions',
84 icon: <Glyph d={ICONS.moon} />,
85 shortcut: ['T'],
86 },
87 { id: 'profile', label: 'Open profile', group: 'Actions', icon: <Glyph d={ICONS.user} /> },
88 {
89 id: 'signout',
90 label: 'Sign out',
91 group: 'Actions',
92 icon: <Glyph d={ICONS.user} />,
93 disabled: true,
94 },
95 ];
96
97 return (
98 <div className="flex w-full max-w-sm flex-col gap-3">
99 <SearchCommand
100 items={items}
101 placeholder="Search docs, components, actions…"
102 onSelect={(item) => setLast(item.label)}
103 />
104 <p className="text-muted-foreground text-xs">
105 {last ? `Selected ${last}` : 'Press ⌘K or click the field'}
106 </p>
107 </div>
108 );
109}

Installation

pnpm dlx nachui add search-command

Anatomy

1import { SearchCommand } from '@/components/ui/search-command';
1<SearchCommand
2 items={[
3 { id: 'docs', label: 'Documentation', group: 'Pages', href: '/docs' },
4 { id: 'theme', label: 'Toggle theme', group: 'Actions', shortcut: ['T'], onSelect: toggle },
5 ]}
6 placeholder="Search…"
7 onSelect={(item) => track(item.id)}
8/>
With no children it renders the trigger and the dialog. The trigger looks like a search field and shows the hotkey; the dialog holds the input, the grouped list and the hint row. Typing filters, arrows move the highlight, Enter selects, Escape closes.
Pass children to keep only what you need. The controlled demo below drops the trigger and opens the palette from a button of its own.
1<SearchCommand items={items} open={open} onOpenChange={setOpen} hotkey={null}>
2 <SearchCommand.Dialog />
3</SearchCommand>

Composition

A hybrid is visible composition. Open search-command.tsx and these are the elements it is made of, in this order:
SearchCommand
├── Dialog
│ ├── SearchCommand.Trigger → Dialog.Trigger + Kbd
│ └── SearchCommand.Dialog → Dialog.Content
│ ├── SearchCommand.Input
│ ├── SearchCommand.List
│ │ ├── SearchCommand.Item → Kbd (shortcut)
│ │ └── SearchCommand.Empty
│ └── SearchCommand.Footer → Kbd
The dialog, its overlay, the focus trap and Escape come from Dialog. The key caps come from Kbd. The filtering and the arrow-key list are the hybrid's own, small enough to read in one sitting.

Hotkey

hotkey defaults to k, so ⌘K on a Mac and Ctrl+K elsewhere toggles the palette. The trigger prints the right modifier for the platform. Pass hotkey={null} to register nothing and open it yourself through open.

Items and groups

Every item has an id and a label. Add group and the list is split under a small heading per group, in the order the groups first appear. description prints under the label, icon on the left, shortcut as key caps on the right, and keywords join the label and description in the search. An item with href and no onSelect renders as a link. disabled keeps it visible but unselectable.

Filtering

The default filter is a case-insensitive match on label, description and keywords. Pass filter to replace it with your own, fuzzy or remote, as long as it answers synchronously for the items you already have.

Controlled

open and onOpenChange put the palette under your control, which is what you want when something else, a dock or a menu, opens it.

Nothing selected yet

1'use client';
2
3import { useState } from 'react';
4import { SearchCommand, type SearchItem } from '@/components/ui/search-command';
5
6const ITEMS: SearchItem[] = [
7 { id: 'new', label: 'New file', group: 'Create', shortcut: ['N'] },
8 { id: 'folder', label: 'New folder', group: 'Create' },
9 { id: 'rename', label: 'Rename', group: 'Edit', shortcut: ['F2'] },
10 { id: 'delete', label: 'Move to trash', group: 'Edit', shortcut: ['⌫'] },
11 { id: 'share', label: 'Share link', group: 'Share' },
12];
13
14export function Controlled() {
15 const [open, setOpen] = useState(false);
16 const [last, setLast] = useState<SearchItem | null>(null);
17
18 return (
19 <div className="flex w-full max-w-sm flex-col items-start gap-3">
20 <button
21 type="button"
22 onClick={() => setOpen(true)}
23 className="border-border hover:bg-muted rounded-full border px-3 py-1.5 text-xs transition-colors"
24 >
25 Open command palette
26 </button>
27 <SearchCommand
28 items={ITEMS}
29 open={open}
30 onOpenChange={setOpen}
31 hotkey={null}
32 onSelect={setLast}
33 placeholder="Type a command…"
34 >
35 <SearchCommand.Dialog />
36 </SearchCommand>
37 <p className="text-muted-foreground text-xs">
38 {last ? `Last: ${last.label}` : 'Nothing selected yet'}
39 </p>
40 </div>
41 );
42}

API Reference

SearchCommand

PropTypeDefaultDescription
itemsSearchItem[]-What the palette searches
openboolean-Controlled open state
defaultOpenbooleanfalseOpen state when uncontrolled
onOpenChange(open: boolean) => void-Called when the open state changes
hotkeystring | null'k'Key that toggles it with ⌘ or Ctrl; null disables
placeholderstringSearch…Text of the trigger and the input
emptyTextstring-Shown when nothing matches
filter(item: SearchItem, query: string) => boolean-Replaces the default match
onSelect(item: SearchItem) => void-Called after the item's own onSelect
closeOnSelectbooleantrueClose the dialog after a selection
labelsPartial<SearchCommandLabels>EnglishPlaceholder, empty, hints and trigger name
classNamestring-Additional CSS classes
childrenReactNode-Replaces the default trigger and dialog

SearchItem

FieldTypeDescription
idstringUnique key, also used for aria-activedescendant
labelstringThe visible name
groupstringHeading the item is listed under
descriptionstringSecond line under the label
iconReactNodeRendered on the left
shortcutstring[]Key caps on the right
keywordsstring[]Extra words the search matches on
hrefstringRenders the item as a link when set
onSelect() => voidRuns before the root onSelect
disabledbooleanVisible but not selectable

SearchCommand.Trigger

PropTypeDefaultDescription
childrenReactNodeplaceholderText of the field
classNamestring-Additional CSS classes

SearchCommand.Dialog

PropTypeDefaultDescription
childrenReactNodeInput, List and FooterReplaces the default
classNamestring-Additional CSS classes

SearchCommand.Input

PropTypeDefaultDescription
classNamestring-Additional CSS classes
...--Any input prop except value and onChange

SearchCommand.List

PropTypeDefaultDescription
classNamestring-Additional CSS classes

SearchCommand.Item

PropTypeDefaultDescription
itemSearchItem-The entry to render
classNamestring-Additional CSS classes

SearchCommand.Empty

PropTypeDefaultDescription
childrenReactNodeemptyTextWhat to say
classNamestring-Additional CSS classes

SearchCommand.Footer

PropTypeDefaultDescription
childrenReactNodeThe three hintsReplaces the hint row
classNamestring-Additional CSS classes

useSearchCommand

ReturnsDescription
{ open, setOpen, query, setQuery, results, ... }The palette state, for parts of your own
Found something to improve?

Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.

Create an Issue