CommandPalette
A Ctrl+K search and command window — fuzzy search, recent items, nested pages and server search.
'use client';
import { Button, CommandPalette, Kbd, toast, type CommandPaletteGroup } from '@akhatmirzo/ui';
import {
ChartColumn,
FileSpreadsheet,
House,
Package,
Plus,
Search,
Settings,
UserPlus,
Users,
} from 'lucide-react';
const run = (label: string) => () => toast(`"${label}" bajarildi`);
const groups: CommandPaletteGroup[] = [
{
heading: 'Sahifalar',
items: [
{ label: 'Bosh sahifa', icon: <House />, onSelect: run('Bosh sahifa') },
{
label: 'Buyurtmalar',
icon: <Package />,
keywords: ['zakaz', 'savdo'],
onSelect: run('Buyurtmalar'),
},
{ label: 'Mijozlar', icon: <Users />, onSelect: run('Mijozlar') },
{
label: 'Hisobotlar',
icon: <ChartColumn />,
keywords: ['statistika'],
onSelect: run('Hisobotlar'),
},
{
label: 'Sozlamalar',
icon: <Settings />,
shortcut: ['mod', ','],
onSelect: run('Sozlamalar'),
},
],
},
{
heading: 'Amallar',
items: [
{
label: 'Yangi buyurtma',
icon: <Plus />,
shortcut: ['mod', 'N'],
onSelect: run('Yangi buyurtma'),
},
{ label: "Mijoz qo'shish", icon: <UserPlus />, onSelect: run("Mijoz qo'shish") },
{
label: 'Eksport (Excel)',
icon: <FileSpreadsheet />,
keywords: ['xlsx'],
onSelect: run('Eksport'),
},
],
},
];
export default function CommandPaletteBasic() {
return (
// Docs saytida Ctrl+K qidiruvga band, shuning uchun bu yerda mod+J
<CommandPalette groups={groups} hotkey="mod+j">
<Button variant="outline" leftIcon={<Search />} className="min-w-64 justify-start">
<span className="flex-1 text-left text-text-muted">Qidirish...</span>
<Kbd keys={['mod', 'J']} />
</Button>
</CommandPalette>
);
}
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
shortcutto 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 samehotkeyon 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).
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.
'use client';
import { Button, CommandPalette, toast, type CommandPaletteGroup } from '@akhatmirzo/ui';
import { Globe, Moon, Sun, User } from 'lucide-react';
const customers = ['Aziza Karimova', 'Bobur Toshmatov', "G'ayrat Usmonov", 'Malika Yusupova'];
const groups: CommandPaletteGroup[] = [
{
heading: 'Sozlamalar',
items: [
{
label: 'Mavzuni almashtirish',
icon: <Moon />,
keywords: ['tungi rejim', 'dark'],
page: {
title: 'Mavzu',
placeholder: 'Mavzu tanlang...',
groups: [
{
heading: 'Mavzu',
items: [
{
id: 'theme-light',
label: "Yorug'",
icon: <Sun />,
onSelect: () => toast("Yorug' mavzu"),
},
{
id: 'theme-dark',
label: "Qorong'i",
icon: <Moon />,
onSelect: () => toast("Qorong'i mavzu"),
},
],
},
],
},
},
{
label: 'Tilni almashtirish',
icon: <Globe />,
page: {
title: 'Til',
groups: [
{
heading: 'Til',
items: [
{ id: 'lang-uz', label: "O'zbekcha", onSelect: () => toast("O'zbekcha") },
{ id: 'lang-en', label: 'English', disabled: true },
],
},
],
},
},
],
},
{
heading: 'Mijozlar',
searchOnly: true,
items: customers.map((name) => ({ label: name, icon: <User />, onSelect: () => toast(name) })),
},
];
export default function CommandPalettePages() {
return (
<CommandPalette groups={groups} hotkey={false} recent={false}>
<Button variant="soft">Sozlamalarni ochish</Button>
</CommandPalette>
);
}
Server search
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.
'use client';
import { Button, CommandPalette, toast, type CommandPaletteItem } from '@akhatmirzo/ui';
import { Plus, User } from 'lucide-react';
const customers = [
['Akmal Ergashev', '+998 90 123 45 67'],
['Akbar Sobirov', '+998 93 555 12 34'],
['Kamola Akramova', '+998 97 700 00 11'],
['Nodira Ahmedova', '+998 91 444 22 33'],
] as const;
/** Server qidiruvini taqlid qiladi (700ms); yangi so'rov eskisini bekor qiladi */
function searchCustomers(query: string, { signal }: { signal: AbortSignal }) {
return new Promise<CommandPaletteItem[]>((resolve, reject) => {
const timer = setTimeout(() => {
const q = query.toLowerCase();
resolve(
customers
.filter(([name]) => name.toLowerCase().includes(q))
.map(([name, phone]) => ({
id: `customer-${name}`,
label: name,
description: phone,
icon: <User />,
onSelect: () => toast(name),
})),
);
}, 700);
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Bekor qilindi', 'AbortError'));
});
});
}
export default function CommandPaletteServer() {
return (
<CommandPalette
hotkey={false}
recentKey="docs:command-palette:server"
placeholder="Mijoz yoki buyruq qidirish..."
onSearch={searchCustomers}
labels={{ results: 'Mijozlar (serverdan)' }}
groups={[
{
heading: 'Amallar',
items: [
{ label: "Mijoz qo'shish", icon: <Plus />, onSelect: () => toast("Mijoz qo'shish") },
],
},
]}
>
<Button variant="outline">Mijoz qidirish</Button>
</CommandPalette>
);
}
<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 (RadixTrigger 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.
| Prop | Type | Default | Description |
|---|---|---|---|
groupsrequired | CommandPaletteGroup[] | — | Groups and their items |
hotkey | string | false | 'mod+k' | Shortcut to open/close; false disables it |
open | boolean | — | Controlled state |
defaultOpen | boolean | false | |
onOpenChange | (open: boolean) => void | — | |
placeholder | string | 'Qidirish yoki buyruq yozish...' | |
onSearch | (query: string, context: CommandPaletteSearchContext) => Promise<CommandPaletteItem[]> | CommandPaletteItem[] | — | Server search (300ms debounce); context: { signal } |
recent | boolean | true | The "recent items" group |
recentKey | string | 'ak:command-palette:recent' | localStorage key |
recentLimit | number | 3 | |
labels | CommandPaletteLabels | — | UI strings |
children | ReactElement | — | Trigger element (asChild) |
container | HTMLElement | null | — | Portal container (defaults to document.body) |
overlayClassName | string | — | |
onOpenAutoFocus | (event: Event) => void | — | |
onCloseAutoFocus | (event: Event) => void | — |
CommandPaletteItem
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Stable key (used for recent items); defaults to label |
labelrequired | string | — | |
description | string | — | Second line (muted) |
icon | ReactNode | — | |
shortcut | string[] | — | Shortcut to display, e.g. ['mod', 'N'] |
keywords | string[] | — | Extra search terms |
disabled | boolean | — | |
onSelect | () => void | — | |
page | CommandPalettePage | — | { title, placeholder?, groups } — a nested page |
keepOpen | boolean | — | 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
| Prop | Type | Default | Description |
|---|---|---|---|
headingrequired | string | — | |
itemsrequired | CommandPaletteItem[] | — | |
searchOnly | boolean | — | 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
| Key | Action |
|---|---|
Ctrl+K / ⌘K | Open / close (hotkey) |
↑ ↓ | Move through items (wraps around) |
Enter | Select |
Esc | Close |
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 througharia-activedescendant. The field'saria-labelislabels.search. - The back button in a nested page includes the page name: "Orqaga: Mavzu".
- Icons are decorative; the
Kbdhints in the footer are visual only. - Animation falls back to a simple fade with
prefers-reduced-motion.