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 section are 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

Or press Ctrl J, or ⌘ J on a Mac.

Nothing opened yet.

Follows the page theme and corners
'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>&#8984;</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

Nothing opened yet.

Follows the page theme and corners
'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.

  • labels replaces the palette's own words: the placeholder (the label by 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) and onRecentChange. The palette then stores nothing. Without recentIds, onRecentChange still receives each stored list, with every id as a string.
  • A remembered id that is no longer in items is dropped.
  • Values in end are shown as given, so format them first. On a right-to-left page, wrap a signed amount in an element with dir="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. null shows items as given, for app-side search.

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 filter={null}, the current results, already ranked and capped.

label
string

Names the dialog and the field, and is the placeholder unless labels.placeholder is set.

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, and strings otherwise.

recentIds
Key[]

Controlled recents, newest first. With onRecentChange, the palette stores nothing.

recentLimit
number

Default: 5

shortcut
string | false

The key that opens and closes it. Mod is Cmd or Ctrl. false turns it off. Inactive while a Dialog, ConfirmDialog, Drawer or Sheet is open.

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.

ModKOpens and closes the palette (Mod is Cmd or Ctrl).
DownUpMove the active row, wrapping.
HomeEndJump to the first or last row.
PageUpPageDownMove the active row by a page of the list.
EnterOpens the active item.
EscapeCloses the palette.
TabStays 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.

esc

↑ ↓ to move↵ to open