Command palette for React
Search and run anything. Ctrl K or ⌘K opens it, arrows move, Enter runs, and groups keep it calm.
One of 9 components in Overlays
Search and run anything from the keyboard
A command palette gives power users one place to search and act, without hunting through menus. This one opens with Ctrl K on Windows and Linux and Cmd K on a Mac, filters as you type, moves with the arrow keys and runs the highlighted item with Enter.
Command list is the inline version that sits in a page or a panel, for example as the search of a docs site. Palette is the floating dialog over the whole app, with groups such as Pages, Actions and Recent, an icon and a shortcut per item, and an empty state that suggests what to type.
Name items by what they do, Create invoice, not Invoices new. Put the five most used actions in the empty state, people open the palette before they know what to type. Every action should also be reachable without the palette, it is a shortcut, not the only way.
Details
What Command palette brings along, read from the registry and the source.
- Category
- Overlays
- Area
- Elements
- Variants
- 2
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/command-palette.tsx
- npm packages
- None
- Languages
- English and German, 7 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CommandProps interface from components/ui/command-palette.tsx, with the comments from the code.
itemsrequiredCommandItem[]defaultQueryoptionalstringPre-filled search, for demos
onDoneoptional() => voidCalled after an item ran, e.g. to close the palette
autoFocusoptionalboolean
Built-in texts
Command palette writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 7 texts
| Key | English | German |
|---|---|---|
| placeholder | English Type a command or search | German Befehl eingeben oder suchen |
| empty | English Nothing found. Try another word. | German Nichts gefunden. Versuch ein anderes Wort. |
| navigate | English to move | German bewegen |
| select | English to open | German öffnen |
| close | English to close | German schließen |
| label | English Command menu | German Befehlsmenü |
| results | English results | German Treffer |
Accessibility
Read from the source of the component.
- Command palette handles the keys Enter, Arrow up, Arrow down, Home, End and Space itself.
- Built on the native dialog element. The browser keeps focus inside and closes it with Escape.
- Sets aria-activedescendant, aria-autocomplete, aria-controls, aria-label, aria-labelledby, aria-live and aria-selected, so screen readers announce purpose and state.
- Uses the roles combobox, group, listbox and option.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 291 lines
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { scrim, useDialogState, useModalMotion } from '@/components/ui/dialog';
const TEXTS = {
en: { placeholder: 'Type a command or search', empty: 'Nothing found. Try another word.', navigate: 'to move', select: 'to open', close: 'to close', label: 'Command menu', results: 'results' },
de: { placeholder: 'Befehl eingeben oder suchen', empty: 'Nichts gefunden. Versuch ein anderes Wort.', navigate: 'bewegen', select: 'öffnen', close: 'schließen', label: 'Befehlsmenü', results: 'Treffer' },
};
type Texts = (typeof TEXTS)['en'];
const CSS = '.kitfx-cmd-empty{animation:kitfx-cmd-in .45s var(--ease-kit) both}@keyframes kitfx-cmd-in{from{opacity:0;transform:translateY(6px) scale(.98)}}@media (prefers-reduced-motion:reduce){.kitfx-cmd-empty{animation:none}}';
export interface CommandItem {
id: string;
label: string;
group?: string;
icon?: IconName;
/** Quiet text after the label, e.g. a path or a category */
hint?: string;
/** Shortcut keys shown at the end, e.g. ['⌘', 'N'] */
keys?: string[];
/** More words that should find this item */
keywords?: string[];
onSelect?: () => void;
href?: string;
}
const norm = (s: string) => s.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
export interface CommandProps {
items: CommandItem[];
/** Pre-filled search, for demos */
defaultQuery?: string;
/** Called after an item ran, e.g. to close the palette */
onDone?: () => void;
autoFocus?: boolean;
labels?: Partial<Texts>;
className?: string;
}
/**
* The searchable command list on its own: input, groups, keyboard (arrows, Home, End, Enter).
* Use it inline (a launcher page, a sidebar) or inside <CommandPalette>.
* The highlight glides between rows, rows glide to their new place while you filter.
*/
export function Command({ items, defaultQuery = '', onDone, autoFocus, labels, className }: CommandProps) {
const t = useTexts(TEXTS, labels);
const [query, setQuery] = useState(defaultQuery);
const [active, setActive] = useState(0);
const id = useId();
const listRef = useRef<HTMLDivElement>(null);
const [mark, setMark] = useState<{ y: number; h: number; animate: boolean } | null>(null);
const places = useRef<Map<string, number> | null>(null);
const shown = useMemo(() => {
const words = norm(query).split(/\s+/).filter(Boolean);
if (!words.length) return items;
return items.filter((i) => {
const hay = norm([i.label, i.hint, i.group, ...(i.keywords ?? [])].join(' '));
return words.every((w) => hay.includes(w));
});
}, [items, query]);
const groups = useMemo(() => {
const out: { name?: string; rows: { item: CommandItem; index: number }[] }[] = [];
shown.forEach((item, index) => {
const g = out.find((x) => x.name === item.group);
if (g) g.rows.push({ item, index });
else out.push({ name: item.group, rows: [{ item, index }] });
});
// Keep the visual order and the keyboard order the same
let k = 0;
out.forEach((g) => g.rows.forEach((r) => (r.index = k++)));
return out;
}, [shown]);
const flat = groups.flatMap((g) => g.rows.map((r) => r.item));
useEffect(() => setActive(0), [query]);
// The highlight is one surface that travels to the active row
useLayoutEffect(() => {
const el = document.getElementById(`${id}-${active}`);
if (!el || !flat.length) {
setMark((m) => (m ? null : m));
return;
}
setMark((m) => {
const next = { y: el.offsetTop, h: el.offsetHeight, animate: m !== null };
return m && m.y === next.y && m.h === next.h ? m : next;
});
const list = listRef.current;
if (!list) return;
const r = el.getBoundingClientRect();
const lr = list.getBoundingClientRect();
if (r.top < lr.top + 8) list.scrollTop -= lr.top + 8 - r.top;
else if (r.bottom > lr.bottom - 8) list.scrollTop += r.bottom - lr.bottom + 8;
}, [active, id, shown]);
// Rows that stay glide from their old place, new rows fade in
useLayoutEffect(() => {
const list = listRef.current;
if (!list) return;
const nodes = Array.from(list.querySelectorAll<HTMLElement>('[data-flip]'));
const now = new Map(nodes.map((n) => [n.dataset.flip!, n.offsetTop]));
const before = places.current;
places.current = now;
if (!before || prefersReducedMotion()) return;
let fresh = 0;
nodes.forEach((n) => {
const was = before.get(n.dataset.flip!);
const top = now.get(n.dataset.flip!)!;
if (was === undefined) {
n.animate([{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'none' }], { duration: 320, delay: Math.min(fresh++, 6) * 25, easing: 'cubic-bezier(0.2,0.7,0.2,1)', fill: 'backwards' });
} else if (was !== top) {
n.animate([{ transform: `translateY(${was - top}px)` }, { transform: 'none' }], { duration: 340, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}
});
}, [shown]);
const run = (item?: CommandItem) => {
if (!item) return;
item.onSelect?.();
if (item.href) window.location.assign(item.href);
onDone?.();
};
const onKeyDown = (e: React.KeyboardEvent) => {
const n = flat.length;
if (!n) return;
if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => (a + 1) % n); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => (a - 1 + n) % n); }
else if (e.key === 'Home' && e.ctrlKey) { e.preventDefault(); setActive(0); }
else if (e.key === 'End' && e.ctrlKey) { e.preventDefault(); setActive(n - 1); }
else if (e.key === 'Enter') { e.preventDefault(); run(flat[active]); }
};
return (
<div className={cn('flex min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-[var(--highlight),var(--shadow-lg)]', className)}>
<style href="kitfx-cmd" precedence="default">
{CSS}
</style>
<div className="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4">
<Icon name="search" size={18} className="text-subtle-foreground" />
<input
role="combobox"
aria-expanded
aria-controls={`${id}-list`}
aria-activedescendant={flat.length ? `${id}-${active}` : undefined}
aria-autocomplete="list"
aria-label={t.label}
autoFocus={autoFocus}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder={t.placeholder}
spellCheck={false}
className="h-full w-0 min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-subtle-foreground sm:text-[15px]"
/>
</div>
<div ref={listRef} id={`${id}-list`} role="listbox" aria-label={t.label} className="kit-scrollbar relative isolate min-h-0 flex-1 overflow-y-auto overscroll-contain p-2">
{mark && (
<span
aria-hidden
className={cn('pointer-events-none absolute inset-x-2 top-0 -z-10 rounded-lg bg-accent', mark.animate && 'transition-transform duration-200 ease-[var(--ease-kit)] motion-reduce:transition-none')}
style={{ height: mark.h, transform: `translateY(${mark.y}px)` }}
/>
)}
{groups.map((g, gi) => (
<div key={g.name ?? gi} role="group" aria-labelledby={g.name ? `${id}-g${gi}` : undefined} className={cn(gi > 0 && 'mt-2')}>
{g.name && (
<div id={`${id}-g${gi}`} data-flip={`g:${g.name}`} className="px-2 pb-1.5 pt-2 text-[11px] font-medium uppercase tracking-[0.08em] text-subtle-foreground">
{g.name}
</div>
)}
{g.rows.map(({ item, index }) => {
const on = index === active;
return (
<div
key={item.id}
id={`${id}-${index}`}
data-flip={item.id}
role="option"
aria-selected={on}
onPointerMove={() => setActive(index)}
onPointerDown={(e) => e.preventDefault()}
onClick={() => run(item)}
className={cn('group/opt flex min-h-11 cursor-pointer items-center gap-3 rounded-lg px-2 text-sm text-foreground sm:min-h-10', on && !mark && 'bg-accent')}
>
{item.icon && (
<span className={cn('grid size-7 shrink-0 place-items-center rounded-md border border-border bg-surface text-muted-foreground transition-[color,scale] duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none', on && 'scale-105 text-foreground')}>
<Icon name={item.icon} size={15} />
</span>
)}
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="truncate">{item.label}</span>
{item.hint && <span className="shrink-0 text-subtle-foreground">{item.hint}</span>}
</span>
{item.keys && (
<span className="flex shrink-0 gap-1 max-sm:hidden">
{item.keys.map((k) => (
<kbd key={k} className="grid h-[22px] min-w-[22px] place-items-center rounded-[5px] border border-border bg-surface px-1.5 font-sans text-[11px] font-medium text-muted-foreground">
{k}
</kbd>
))}
</span>
)}
{!item.keys && (
<Icon
name="corner-down-left"
size={15}
className={cn('shrink-0 text-subtle-foreground transition-[opacity,translate] duration-200 ease-[var(--ease-kit)] max-sm:hidden motion-reduce:transition-none', on ? 'translate-x-0 opacity-100' : '-translate-x-1 opacity-0')}
/>
)}
</div>
);
})}
</div>
))}
{!flat.length && (
<div className="kitfx-cmd-empty grid place-items-center gap-3 px-6 py-12 text-center">
<span className="grid size-10 place-items-center rounded-full bg-muted text-muted-foreground">
<Icon name="search" size={18} />
</span>
<p className="text-sm text-muted-foreground">{t.empty}</p>
</div>
)}
</div>
<span aria-live="polite" className="sr-only">
{query ? `${flat.length} ${t.results}` : ''}
</span>
<div className="flex shrink-0 items-center gap-4 border-t border-border bg-surface px-4 py-2.5 text-xs text-muted-foreground max-sm:hidden">
{[
[['↑', '↓'], t.navigate],
[['↵'], t.select],
[['Esc'], t.close],
].map(([keys, label]) => (
<span key={label as string} className="flex items-center gap-1.5">
{(keys as string[]).map((k) => (
<kbd key={k} className="grid h-5 min-w-5 place-items-center rounded-[4px] border border-border bg-background px-1 font-sans text-[10.5px] font-medium">
{k}
</kbd>
))}
{label as string}
</span>
))}
</div>
</div>
);
}
export interface CommandPaletteProps extends Omit<CommandProps, 'onDone' | 'autoFocus'> {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** The key that opens it with Ctrl or ⌘. Default "k". false turns the shortcut off. */
shortcut?: string | false;
}
/**
* The command menu in a modal: Ctrl K or ⌘K opens it anywhere, Escape closes it.
* It grows out of the button that opened it and shrinks back when it closes.
* <CommandPalette items={[{ id: 'new', label: 'New invoice', group: 'Actions', icon: 'plus', keys: ['N'] }]} />
*/
export function CommandPalette({ open: openProp, defaultOpen, onOpenChange, shortcut = 'k', ...rest }: CommandPaletteProps) {
const [open, setOpen] = useDialogState(openProp, defaultOpen, onOpenChange);
const [modal, present] = useModalMotion(open, setOpen);
const t = useTexts(TEXTS, rest.labels);
useEffect(() => {
if (!shortcut) return;
const onKey = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === shortcut && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen(!open);
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [shortcut, open, setOpen]);
return (
<dialog
{...modal}
aria-label={t.label}
className={cn('mx-auto mt-[12vh] mb-auto w-[calc(100%-1.5rem)] max-w-[40rem] overflow-visible bg-transparent p-0 text-left outline-none data-[closing]:pointer-events-none max-sm:mt-3', scrim)}
>
{present && <Command {...rest} autoFocus onDone={() => setOpen(false)} className="max-h-[min(30rem,76vh)]" />}
</dialog>
);
}Frequent questions
How do I install Command palette?
With aiw add @ai-website/command-palette. The command puts components/ui/command-palette.tsx into your project, the code is yours from then on. It brings along icon and dialog. It needs no further npm packages.
Which variants does Command palette have?
2 variants, Command list and Palette.
Can I translate the texts of Command palette?
Yes. Command palette carries 7 texts of its own in English and German, such as “Type a command or search”, “Nothing found. Try another word.” and “to move”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Command palette?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Command palette with aiw_components and install it with aiw_add @ai-website/command-palette.