@akhatmirzo/ui

CommandPalette

A Ctrl+K search and command window — fuzzy search, recent items, nested pages and server search.

Import

import { CommandPalette } from '@akhatmirzo/ui';

When to use

  • Fast navigation in big apps — admin panels, CRMs, internal tools. No digging through menus: Ctrl+K, two or three letters, Enter.
  • Frequent actions — "New order", "Export", "Switch theme". Use shortcut to show the app's own keyboard shortcuts next to each item.
  • One per app — usually in the layout, with a global hotkey. Two palettes with the same hotkey on one page open together.
  • Not a replacement for navigation — it speeds up experienced users; you still need menus and links.

Ctrl+K is taken on this site

These docs use Ctrl+K for their own search, so the demo above uses hotkey="mod+j". Keep the default mod+k in your app — mod is ⌘ on macOS and Ctrl everywhere else.

Groups and items

groups is a list of headed groups. Each item has a label and optionally a description, icon, shortcut, keywords (synonyms) and onSelect. The palette closes after a selection (keepOpen: true keeps it open).

AppCommands.tsx
import { CommandPalette, type CommandPaletteGroup } from '@akhatmirzo/ui';
import { Package, Plus } from 'lucide-react';
import { useRouter } from 'next/navigation';

export function AppCommands() {
  const router = useRouter();
  const groups: CommandPaletteGroup[] = [
    {
      heading: 'Sahifalar',
      items: [
        {
          label: 'Buyurtmalar',
          icon: <Package />,
          keywords: ['zakaz', 'savdo'],
          onSelect: () => router.push('/orders'),
        },
      ],
    },
    {
      heading: 'Amallar',
      items: [
        {
          label: 'Yangi buyurtma',
          icon: <Plus />,
          shortcut: ['mod', 'N'],
          onSelect: () => router.push('/orders/new'),
        },
      ],
    },
  ];

  return <CommandPalette groups={groups} />;
}

Search is fuzzy — it matches letters in order: ybu → "Yangi buyurtma". Uzbek text is normalised, so qo'sh, qoʻsh and the Cyrillic қўш all find the same item. Results are ranked by match quality, and matching letters are highlighted.

Recent items

With an empty query, the first group is "Oxirgi ishlatilganlar" (recent: 3 by default, stored in localStorage under recentKey). Items are remembered by id, or by label when there's no id. With several palettes in one app, give each its own recentKey; recent={false} turns the group off.

Nested pages and search-only groups

Give an item a page and selecting it opens a new list — for example "Theme" → Light / Dark. A back button appears next to the search field, and Backspace on an empty query also goes back. A group with searchOnly: true only appears while searching — great for long lists like customers or products. Try searching for aziza.

onSearch(query, { signal }) searches on the server. Requests are debounced by 300ms, results appear in a separate "Natijalar" group (labels.results), and a spinner shows while waiting. A new query or closing the palette aborts the previous request through signal. Local groups keep filtering at the same time; inside nested pages, onSearch isn't called. Try ak or nodira.

<CommandPalette
  groups={groups}
  onSearch={async (query, { signal }) => {
    const res = await fetch(`/api/customers?q=${encodeURIComponent(query)}`, { signal });
    const customers: { id: number; name: string; phone: string }[] = await res.json();
    return customers.map((c) => ({
      id: `customer-${c.id}`,
      label: c.name,
      description: c.phone,
      onSelect: () => router.push(`/customers/${c.id}`),
    }));
  }}
/>

Server results aren't added to the recent items.

Opening and control

There are three ways, and they work together:

  • hotkey — a global keyboard shortcut (default 'mod+k'; it works inside inputs too). hotkey={false} turns it off.
  • children — the trigger element (Radix Trigger asChild), usually a button.
  • open / onOpenChange — controlled state, e.g. to open it from a "Search" menu item.

On close, focus returns to whatever opened the palette.

Props

Remaining attributes (className, aria-*, data-* ...) go to the dialog panel.

PropTypeDefaultDescription
groupsrequiredCommandPaletteGroup[]—Groups and their items
hotkeystring | false'mod+k'Shortcut to open/close; false disables it
openboolean—Controlled state
defaultOpenbooleanfalse
onOpenChange(open: boolean) => void—
placeholderstring'Qidirish yoki buyruq yozish...'
onSearch(query: string, context: CommandPaletteSearchContext) => Promise<CommandPaletteItem[]> | CommandPaletteItem[]—Server search (300ms debounce); context: { signal }
recentbooleantrueThe "recent items" group
recentKeystring'ak:command-palette:recent'localStorage key
recentLimitnumber3
labelsCommandPaletteLabels—UI strings
childrenReactElement—Trigger element (asChild)
containerHTMLElement | null—Portal container (defaults to document.body)
overlayClassNamestring—
onOpenAutoFocus(event: Event) => void—
onCloseAutoFocus(event: Event) => void—

CommandPaletteItem

PropTypeDefaultDescription
idstring—Stable key (used for recent items); defaults to label
labelrequiredstring—
descriptionstring—Second line (muted)
iconReactNode—
shortcutstring[]—Shortcut to display, e.g. ['mod', 'N']
keywordsstring[]—Extra search terms
disabledboolean—
onSelect() => void—
pageCommandPalettePage—{ title, placeholder?, groups } — a nested page
keepOpenboolean—true — the palette stays open after selection

shortcut is display-only

shortcut draws the keys next to the item but doesn't listen for them. To make the shortcut work while the palette is closed, wire it up separately with useHotkeys.

CommandPaletteGroup

PropTypeDefaultDescription
headingrequiredstring—
itemsrequiredCommandPaletteItem[]—
searchOnlyboolean—Only shown while searching (hidden for an empty query)

Strings (labels)

title (dialog title for screen readers), search, list, recent, results, loading, empty, emptyDescription ((query) => ReactNode), back, and navigate, select, close — the words in the footer hint.

<CommandPalette
  groups={groups}
  labels={{ empty: 'No commands found', emptyDescription: (q) => `Nothing matches “${q}”` }}
/>

Keyboard

KeyAction
Ctrl+K / ⌘KOpen / close (hotkey)
↑ ↓Move through items (wraps around)
EnterSelect
EscClose
Backspace (empty query)Back from a nested page

Accessibility

  • Modal dialog (Radix): focus is trapped inside, lands in the search field on open and returns to the trigger on close. The dialog title is visually hidden but announced (labels.title, "Buyruqlar" by default).
  • Search and list are built on cmdk: a combobox with a listbox, and the active item is announced through aria-activedescendant. The field's aria-label is labels.search.
  • The back button in a nested page includes the page name: "Orqaga: Mavzu".
  • Icons are decorative; the Kbd hints in the footer are visual only.
  • Animation falls back to a simple fade with prefers-reduced-motion.

On this page