A search-and-jump dialog on Cmd or Ctrl+K, with recent items and an async mode.
Basic
A dialog with a search field over a list. Type to filter, use the arrows to move, and press Enter to open the active item.
- Filtering: an item matches when every word you type appears in its label, second line or keywords, ignoring case and accents. "cafe lumiere" finds Café Lumière.
- Items: each can have an icon, a second line, a preformatted value at its end, and a key hint.
- Sections: items with a
sectionare grouped under it, after the items without one. - Recent: with
storageKey, the last five chosen items are listed first while the field is empty.
Cmd or Ctrl+K opens and closes the palette, and shortcut changes the key. It also works on layouts that type another script, such as Persian or Russian. This site's search already uses Cmd or Ctrl+K, so the example below uses Cmd or Ctrl+J.
Example
Example
Or press Ctrl J, or ⌘ J on a Mac.
Nothing opened yet.
'use client';
import { Button, CommandPalette, Kbd, Text, type CommandPaletteItem } from '@snapshot-ui/react';
import {
ArrowLeftRight,
ChartColumn,
Coffee,
CreditCard,
Landmark,
PiggyBank,
Plus,
Receipt,
Settings,
Wallet,
} from 'lucide-react';
import { useState, type ReactElement } from 'react';
const ITEMS: CommandPaletteItem[] = [
{ id: 'new', label: 'New transaction', icon: <Plus />, keywords: 'add', shortcut: 'Mod N' },
{ id: 'settings', label: 'Settings', description: 'Profile, billing and security', icon: <Settings /> },
{
id: 'checking',
label: 'Checking',
description: 'Everyday',
icon: <Landmark />,
end: '$2,340.12',
shortcut: 'Mod 1',
section: 'Accounts',
},
{
id: 'savings',
label: 'Savings',
description: 'Emergency buffer',
icon: <Wallet />,
end: '$10,050.00',
shortcut: 'Mod 2',
section: 'Accounts',
},
{ id: 'credit', label: 'Credit card', icon: <CreditCard />, end: '$412.30', section: 'Accounts' },
{
id: 'transactions',
label: 'Transactions',
description: 'Every account',
icon: <ArrowLeftRight />,
section: 'Pages',
},
{ id: 'budgets', label: 'Budgets', icon: <PiggyBank />, keywords: 'spending limits', section: 'Pages' },
{ id: 'reports', label: 'Reports', icon: <ChartColumn />, section: 'Pages' },
{
id: 'rent',
label: 'Rent',
description: 'Paid on the 1st',
icon: <Receipt />,
end: '$1,850.00',
section: 'Payees',
},
{
id: 'cafe',
label: 'Café Lumière',
description: 'Coffee and pastries',
icon: <Coffee />,
end: '$4.50',
section: 'Payees',
},
];
export default function CommandPaletteBasic(): ReactElement {
const [isOpen, setOpen] = useState(false);
const [opened, setOpened] = useState<string | null>(null);
return (
<div style={{ display: 'grid', gap: 16, justifyItems: 'center', textAlign: 'center' }}>
<Button
variant="secondary"
onPress={() => {
setOpen(true);
}}
>
Search the app
</Button>
<Text tone="muted">
Or press <Kbd>Ctrl</Kbd> <Kbd>J</Kbd>, or <Kbd>⌘</Kbd> <Kbd>J</Kbd> on a Mac.
</Text>
<CommandPalette
label="Search the app"
labels={{ placeholder: 'Search accounts, pages and payees' }}
items={ITEMS}
isOpen={isOpen}
onOpenChange={setOpen}
// This site's own search owns Cmd or Ctrl+K.
shortcut="Mod+J"
storageKey="docs-palette"
onAction={(item) => {
// The app navigates here, once the palette has closed and focus is back.
setOpened(item.label);
}}
/>
<Text tone="muted">{opened === null ? 'Nothing opened yet.' : `Opened ${opened}.`}</Text>
</div>
);
}Async results
To search on your server, pass filter={null}, and the palette shows items as given. Control the field with inputValue and onInputChange, then fetch, rank and cap the results yourself. Set isLoading while a request runs: the field shows a spinner, and the list keeps its results until new ones arrive. Here a 400ms timer stands in for the request.
Example
Example
Nothing opened yet.
'use client';
import { Button, CommandPalette, Text, type CommandPaletteItem } from '@snapshot-ui/react';
import { useEffect, useRef, useState, type ReactElement } from 'react';
/** The app's index, which a real app would search on its server. */
const INDEX: CommandPaletteItem[] = [
{ id: 't1', label: 'Fresh Market', description: 'Groceries, Oct 9', end: '$84.20', section: 'October' },
{ id: 't2', label: 'City Transit', description: 'Travel, Oct 8', end: '$2.75', section: 'October' },
{ id: 't3', label: 'Café Lumière', description: 'Eating out, Oct 6', end: '$4.50', section: 'October' },
{ id: 't4', label: 'Northwind Rent', description: 'Housing, Oct 1', end: '$1,850.00', section: 'October' },
{ id: 't5', label: 'Fresh Market', description: 'Groceries, Sep 29', end: '$61.05', section: 'September' },
{
id: 't6',
label: 'Power and Light',
description: 'Utilities, Sep 24',
end: '$96.40',
section: 'September',
},
{ id: 't7', label: 'City Transit', description: 'Travel, Sep 22', end: '$2.75', section: 'September' },
{
id: 't8',
label: 'Bookshop on Main',
description: 'Shopping, Sep 18',
end: '$23.99',
section: 'September',
},
];
/** The most results the app shows at once. */
const CAP = 6;
/** The app's own search: every word in the label or the description, labels that start with the query first. */
function search(query: string): CommandPaletteItem[] {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const text = (item: CommandPaletteItem): string => `${item.label} ${item.description ?? ''}`.toLowerCase();
return INDEX.filter((item) => words.every((word) => text(item).includes(word)))
.sort(
(a, b) =>
Number(b.label.toLowerCase().startsWith(words[0] ?? '')) -
Number(a.label.toLowerCase().startsWith(words[0] ?? '')),
)
.slice(0, CAP);
}
export default function CommandPaletteAsync(): ReactElement {
const [isOpen, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [results, setResults] = useState<CommandPaletteItem[]>(INDEX.slice(0, 4));
const [isLoading, setLoading] = useState(false);
const [opened, setOpened] = useState<string | null>(null);
const pending = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(
() => () => {
clearTimeout(pending.current);
},
[],
);
const change = (value: string): void => {
setQuery(value);
clearTimeout(pending.current);
// The list keeps what it has while the next results load.
setLoading(true);
// A stand-in for a request to the server, 400ms away.
pending.current = setTimeout(() => {
setResults(value.trim() === '' ? INDEX.slice(0, 4) : search(value));
setLoading(false);
}, 400);
};
return (
<div style={{ display: 'grid', gap: 16, justifyItems: 'center' }}>
<Button
variant="secondary"
onPress={() => {
setOpen(true);
}}
>
Search transactions
</Button>
<CommandPalette
label="Search transactions"
items={results}
filter={null}
inputValue={query}
onInputChange={change}
isLoading={isLoading}
// One shortcut per page: this one opens from its button only.
shortcut={false}
isOpen={isOpen}
onOpenChange={setOpen}
onAction={(item) => {
setOpened(`${item.label}, ${item.description ?? ''}`);
}}
/>
<Text tone="muted">{opened === null ? 'Nothing opened yet.' : `Opened ${opened}.`}</Text>
</div>
);
}Usage
Open the palette from your own button with isOpen and onOpenChange, and show the key beside it with a Kbd. Navigate in onAction: it runs after the palette has closed and focus has returned, so nothing undoes your navigation. The palette has no href or routing of its own.
'use client';
import { CommandPalette } from '@snapshot-ui/react';
import { useRouter } from 'next/navigation';
import { PAGES } from './pages';
interface AppSearchProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}
export function AppSearch({ isOpen, onOpenChange }: AppSearchProps) {
const router = useRouter();
return (
<CommandPalette
label="Search"
items={PAGES}
isOpen={isOpen}
onOpenChange={onOpenChange}
storageKey="app-search"
onAction={(item) => {
router.push(`/${String(item.id)}`);
}}
/>
);
}The shortcut listens on the whole page while the palette is mounted. It skips keys another handler has already taken and keys typed while composing text, and it is off while a Dialog, ConfirmDialog, Drawer or Sheet is open. For a modal of your own, call preventDefault() on the shortcut's keydown inside it. shortcut={false} turns the shortcut off.
labelsreplaces the palette's own words: the placeholder (thelabelby default), the empty line, the spinner's name, the Recent heading, the status line and the key hints.- To keep recent items yourself, such as on your server, pass
recentIds(newest first) andonRecentChange. The palette then stores nothing. WithoutrecentIds,onRecentChangestill receives each stored list, with every id as a string. - A remembered id that is no longer in
itemsis dropped. - Values in
endare shown as given, so format them first. On a right-to-left page, wrap a signed amount in an element withdir="ltr"to keep its sign in front.
Props
ref goes on the dialog element and className on the surface.
className string | Goes on the surface. |
|---|---|
defaultInputValue string | |
defaultOpen boolean | |
filter ((item: CommandPaletteItem, query: string) => boolean) | null | Keeps or drops an item for a query. By default, every word must appear in the label, description or
keywords, ignoring case and accents. |
inputValue string | |
isLoading boolean | Shows a spinner in the field while results load. The list keeps its current results. |
isOpen boolean | |
items CommandPaletteItem[] | The items to search. With |
label string | Names the dialog and the field, and is the placeholder unless |
labels Partial<CommandPaletteLabels> | |
onAction (item: CommandPaletteItem) => void | Runs with the chosen item after the palette closes and focus returns. Navigate here. |
onInputChange ((value: string) => void) | |
onOpenChange ((isOpen: boolean) => void) | |
onRecentChange ((ids: Key[]) => void) | Called with the new list, newest first, each time an item is chosen. Ids are your keys with |
recentIds Key[] | Controlled recents, newest first. With |
recentLimit number | Default: 5 |
shortcut string | false | The key that opens and closes it. Default: 'Mod+K' |
storageKey string | Remembers chosen items in this browser under this key. |
description string | A subtitle under the label, matched too. |
|---|---|
end ReactNode | A preformatted value at the end, such as an amount. Never formatted here. |
icon ReactNode | A 16px icon at the start. |
id Key | |
isDisabled boolean | |
keywords string | Extra text that matches but is not drawn. |
label string | The label, matched by the default filter and announced. |
section string | Groups items under this heading. Items without one come first. |
shortcut string | A key hint, drawn as keys. "Mod" shows as Cmd or Ctrl once mounted. |
empty string | Default: 'No results' |
|---|---|
escape string | Default: 'Esc' |
loading string | Default: 'Loading' |
move string | Default: 'to move' |
open string | Default: 'to open' |
placeholder string | Default: the `label` |
recent string | Default: 'Recent' |
status (count: number, query: string) => string | The status line. Replace it to translate, plurals included. Default: (count, query) => count === 0 ? `No results for ${query}` : count === 1 ? '1 result' : `${count} results` |
Accessibility
The dialog and its field are both named by label. Focus stays in the field while the arrows move a virtual active row, which a screen reader announces and which is drawn as the ink fill. Once typing pauses, a status line announces the number of results. Escape closes the palette and returns focus.
| ModK | Opens and closes the palette (Mod is Cmd or Ctrl). |
|---|---|
| DownUp | Move the active row, wrapping. |
| HomeEnd | Jump to the first or last row. |
| PageUpPageDown | Move the active row by a page of the list. |
| Enter | Opens the active item. |
| Escape | Closes the palette. |
| Tab | Stays in the dialog. |
The list is React Aria's menu. How VoiceOver and NVDA read it is not yet checked. If it reads badly, it becomes a listbox, with the same props.
Design rules
What keeps a command palette in the system.
Label every item with what it opens, such as Budgets or New transaction.
Give amounts in end, already formatted.
Keep sections few, so the list reads in a glance.
Never route inside the palette. Navigate in onAction.
Never fill the empty field with a list of suggestions yet. Recent is that list.
Never put more than one shortcut listener on a page.