Command Palette
A ⌘K / Ctrl+K global command palette with grouped results, fuzzy match highlighting, arrow-key navigation and shortcut hints.
Press ⌘ K anywhere on the page; try typing “spm” or “theme”.
'use client';
import { useState } from 'react';
import { useTheme } from 'next-themes';
import {
ChartBar,
FileText,
Folder,
Gear,
House,
MagnifyingGlass,
Moon,
Note,
NotePencil,
Plus,
SignOut,
} from '@phosphor-icons/react';
import { Button } from '@/components/ui/button';
import { Kbd } from '@/components/ui/input';
import { CommandPalette, useCommandPalette, useModKey, type CommandItem } from '@/components/blocks/command-palette';
/** Sample commands (reused by the app-shell demo) */
export function useDemoCommands(onRun: (label: string) => void): CommandItem[] {
const { resolvedTheme, setTheme } = useTheme();
const mod = useModKey();
return [
{ id: 'p-home', group: 'pages', label: 'Home', icon: <House />, onRun: () => onRun('Home') },
{ id: 'p-projects', group: 'pages', label: 'Projects', icon: <Folder />, onRun: () => onRun('Projects') },
{ id: 'p-analytics', group: 'pages', label: 'Analytics', icon: <ChartBar />, keywords: ['stats', 'charts'], onRun: () => onRun('Analytics') },
{ id: 'p-settings', group: 'pages', label: 'Settings', icon: <Gear />, keywords: ['preferences'], shortcut: [mod, ','], onRun: () => onRun('Settings') },
{ id: 'n-1', group: 'notes', label: 'Design token audit', hint: 'Notes / Design', icon: <Note />, onRun: () => onRun('Design token audit') },
{ id: 'n-2', group: 'notes', label: 'Spring motion presets', hint: 'Notes / Motion', icon: <Note />, onRun: () => onRun('Spring motion presets') },
{ id: 'n-3', group: 'notes', label: 'Q4 release plan', hint: 'Notes / Planning', icon: <FileText />, onRun: () => onRun('Q4 release plan') },
{ id: 'c-new', group: 'commands', label: 'New note', icon: <Plus />, shortcut: [mod, 'N'], onRun: () => onRun('New note') },
{ id: 'c-edit', group: 'commands', label: 'Rename current page', icon: <NotePencil />, shortcut: ['F2'], onRun: () => onRun('Rename current page') },
{
id: 'c-theme',
group: 'commands',
label: resolvedTheme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme',
icon: <Moon />,
keywords: ['theme', 'appearance'],
shortcut: [mod, 'J'],
onRun: () => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'),
},
{ id: 'c-signout', group: 'commands', label: 'Sign out', icon: <SignOut />, keywords: ['logout'], onRun: () => onRun('Sign out') },
];
}
export default function CommandPaletteDemo() {
const palette = useCommandPalette();
const mod = useModKey();
const [last, setLast] = useState<string | null>(null);
const items = useDemoCommands(setLast);
return (
<div className="flex flex-col items-center gap-4">
<Button variant="outline" className="w-[280px] justify-start text-text-muted" onClick={() => palette.setOpen(true)}>
<MagnifyingGlass />
<span className="flex-1 text-left">Search or run a command…</span>
<Kbd>{mod}</Kbd>
<Kbd>K</Kbd>
</Button>
<p className="text-[12px] text-text-muted" aria-live="polite">
{last ? (
<>
Ran: <span className="text-text-primary">{last}</span>
</>
) : (
<>Press {mod} K anywhere on the page; try typing “spm” or “theme”.</>
)}
</p>
<CommandPalette {...palette.props} lang="en" items={items} />
</div>
);
}
Includes
- Top-aligned
Dialog(align="top") +SearchInput; Enter and arrow keys are IME-guarded - Grouped results: Pages / Notes / Commands, each with icon, hint and
Kbdshortcut - Fuzzy matching (substring first, then subsequence; contiguous and word-start bonuses) with accent-highlighted hits;
keywordsfor aliases - ↑↓ navigation with a SNAPPY
layoutIdhighlight that follows the pointer; ↵ runs, esc closes EmptyStatewhen nothing matches; footer hints ↑↓ select · ↵ open · esc closeuseCommandPalette()registers the ⌘K / Ctrl+K hotkey;useModKey()returns ⌘ or Ctrl
Usage
import { CommandPalette, useCommandPalette, type CommandItem } from "@/components/blocks/command-palette";
const palette = useCommandPalette(); // toggles on ⌘K / Ctrl+K
const items: CommandItem[] = [
{ id: "home", group: "pages", label: "Home", icon: <House />, onRun: () => go("/") },
{ id: "new", group: "commands", label: "New note", icon: <Plus />, shortcut: ["⌘", "N"], onRun: newNote },
];
<Button onClick={() => palette.setOpen(true)}>Search <Kbd>⌘</Kbd><Kbd>K</Kbd></Button>
<CommandPalette {...palette.props} lang="en" items={items} />Source
'use client';
import { motion } from 'motion/react';
import { MagnifyingGlass } from '@phosphor-icons/react';
import {
Fragment,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from 'react';
import { twMerge } from 'tailwind-merge';
import { Dialog } from '@/components/ui/dialog';
import { Kbd, SearchInput } from '@/components/ui/input';
import { EmptyState } from '@/components/ui/feedback';
import { SPRING_SNAPPY } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
const COPY = {
zh: {
dialog: '命令面板',
placeholder: '搜索页面、笔记或命令…',
results: '搜索结果',
clear: '清除',
close: '关闭',
emptyTitle: '没有找到结果',
emptyDesc: (q: string) => `没有与“${q}”匹配的页面、笔记或命令。`,
select: '选择',
open: '打开',
esc: '关闭',
groups: { pages: '页面', notes: '笔记', commands: '命令' } as Record<string, string>,
},
en: {
dialog: 'Command palette',
placeholder: 'Search pages, notes or commands…',
results: 'Results',
clear: 'Clear',
close: 'Close',
emptyTitle: 'No results',
emptyDesc: (q: string) => `Nothing matches “${q}” in pages, notes or commands.`,
select: 'select',
open: 'open',
esc: 'close',
groups: { pages: 'Pages', notes: 'Notes', commands: 'Commands' } as Record<string, string>,
},
};
export interface CommandItem {
id: string;
label: string;
/** 分组 id:pages / notes / commands,或任意自定义字符串 */
group: string;
icon?: ReactNode;
/** 副标题(如笔记路径) */
hint?: string;
/** 快捷键键帽,如 ['⌘', 'N'] */
shortcut?: string[];
/** 额外的匹配词(拼音、别名) */
keywords?: string[];
onRun?: () => void;
}
export interface CommandPaletteProps {
open: boolean;
onOpenChange: (o: boolean) => void;
items: CommandItem[];
lang?: 'zh' | 'en';
/** 分组顺序与标题;默认 pages / notes / commands */
groups?: { id: string; label: string }[];
/** 任一条目被执行(在 item.onRun 之后调用) */
onRun?: (item: CommandItem) => void;
placeholder?: string;
/** 每组最多显示条数,默认 8 */
limit?: number;
}
/* ---------- 模糊匹配:子序列 + 连续 / 词首加分,返回命中下标 ---------- */
export function fuzzyMatch(text: string, query: string): { score: number; indices: number[] } | null {
const q = query.trim().toLowerCase();
if (!q) return { score: 0, indices: [] };
const s = text.toLowerCase();
// 优先整段子串命中
const at = s.indexOf(q);
if (at >= 0) {
return { score: 100 - at + (at === 0 ? 50 : 0), indices: Array.from({ length: q.length }, (_, i) => at + i) };
}
const indices: number[] = [];
let score = 0;
let prev = -2;
let j = 0;
for (let i = 0; i < s.length && j < q.length; i++) {
if (q[j] === ' ') {
j++;
i--;
continue;
}
if (s[i] === q[j]) {
indices.push(i);
score += i === prev + 1 ? 6 : 1;
if (i === 0 || /[\s\-_/.]/.test(s[i - 1])) score += 4;
prev = i;
j++;
}
}
if (j < q.replace(/\s+$/, '').length) return null;
return { score: score - s.length * 0.05, indices };
}
function Highlight({ text, indices }: { text: string; indices: number[] }) {
if (!indices.length) return <>{text}</>;
const set = new Set(indices);
const parts: { s: string; hit: boolean }[] = [];
for (let i = 0; i < text.length; i++) {
const hit = set.has(i);
const last = parts[parts.length - 1];
if (last && last.hit === hit) last.s += text[i];
else parts.push({ s: text[i], hit });
}
return (
<>
{parts.map((p, i) =>
p.hit ? (
<mark key={i} className="rounded-xs bg-transparent font-semibold text-accent-text">
{p.s}
</mark>
) : (
<Fragment key={i}>{p.s}</Fragment>
),
)}
</>
);
}
/** 当前平台的修饰键显示:⌘ 或 Ctrl(挂载后判断) */
export function useModKey() {
const [mod, setMod] = useState('⌘');
useEffect(() => {
const p = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? navigator.platform ?? '';
if (!/mac|iphone|ipad/i.test(p)) setMod('Ctrl');
}, []);
return mod;
}
/** ⌘K / Ctrl+K 全局快捷键:返回 open 状态与可直接展开到 <CommandPalette /> 的 props */
export function useCommandPalette({ hotkey = 'k', enabled = true }: { hotkey?: string; enabled?: boolean } = {}) {
const [open, setOpen] = useState(false);
useEffect(() => {
if (!enabled) return;
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.isComposing || e.keyCode === 229) return;
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === hotkey) {
e.preventDefault();
setOpen((o) => !o);
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [hotkey, enabled]);
const toggle = useCallback(() => setOpen((o) => !o), []);
return { open, setOpen, toggle, props: { open, onOpenChange: setOpen } };
}
/** 命令面板:靠上 Dialog + 搜索(IME 保护)+ 分组结果 + ↑↓ SNAPPY 高亮 + ↵ 执行 */
export function CommandPalette({ open, onOpenChange, items, lang = 'zh', groups, onRun, placeholder, limit = 8 }: CommandPaletteProps) {
const t = COPY[lang];
const uid = useId();
const reduce = useReducedMotionPref();
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const order = groups ?? ['pages', 'notes', 'commands'].map((id) => ({ id, label: t.groups[id] }));
useEffect(() => {
if (!open) {
const tm = setTimeout(() => {
setQuery('');
setActive(0);
}, 150);
return () => clearTimeout(tm);
}
}, [open]);
const sections = useMemo(() => {
const known = new Set(order.map((g) => g.id));
const allGroups = [...order, ...[...new Set(items.map((i) => i.group))].filter((g) => !known.has(g)).map((id) => ({ id, label: id }))];
return allGroups
.map((g) => {
const rows = items
.filter((it) => it.group === g.id)
.map((it) => {
const m = fuzzyMatch(it.label, query);
const kw = m ? null : (it.keywords ?? []).map((k) => fuzzyMatch(k, query)).find(Boolean);
const hit = m ?? (kw ? { score: kw.score - 20, indices: [] } : null);
return hit ? { item: it, ...hit } : null;
})
.filter((r): r is NonNullable<typeof r> => r !== null)
.sort((a, b) => (query ? b.score - a.score : 0))
.slice(0, limit);
return { ...g, rows };
})
.filter((g) => g.rows.length);
}, [items, query, order, limit]);
const flat = useMemo(() => sections.flatMap((s) => s.rows.map((r) => r.item)), [sections]);
const clamped = Math.min(active, Math.max(0, flat.length - 1));
useEffect(() => setActive(0), [query]);
useEffect(() => {
const el = listRef.current?.querySelector<HTMLElement>(`[data-index="${clamped}"]`);
el?.scrollIntoView({ block: 'nearest' });
}, [clamped]);
const exec = (it?: CommandItem) => {
if (!it) return;
onOpenChange(false);
// 等 Dialog 关闭、焦点还原后再执行
requestAnimationFrame(() => {
it.onRun?.();
onRun?.(it);
});
};
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (!flat.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive((clamped + 1) % flat.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((clamped - 1 + flat.length) % flat.length);
} else if (e.key === 'Home' && e.ctrlKey) {
e.preventDefault();
setActive(0);
} else if (e.key === 'End' && e.ctrlKey) {
e.preventDefault();
setActive(flat.length - 1);
} else if (e.key === 'Enter') {
e.preventDefault();
exec(flat[clamped]);
}
};
const optId = (i: number) => `${uid}-opt-${i}`;
let idx = -1;
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
align="top"
size="lg"
hideClose
aria-label={t.dialog}
closeLabel={t.close}
className="overflow-hidden p-0"
>
<div className="border-b border-hairline p-2">
<SearchInput
data-autofocus=""
hotkey={false}
size="lg"
value={query}
onValueChange={setQuery}
onKeyDown={onKey}
placeholder={placeholder ?? t.placeholder}
clearLabel={t.clear}
aria-label={t.placeholder}
aria-controls={`${uid}-list`}
aria-activedescendant={flat.length ? optId(clamped) : undefined}
aria-autocomplete="list"
autoComplete="off"
spellCheck={false}
className="bg-transparent shadow-none hover:shadow-none focus-within:shadow-none!"
/>
</div>
<div ref={listRef} id={`${uid}-list`} role="listbox" aria-label={t.results} className="max-h-[min(52vh,380px)] overflow-y-auto p-2">
{flat.length === 0 ? (
<EmptyState icon={<MagnifyingGlass />} title={t.emptyTitle} description={t.emptyDesc(query)} className="py-8" />
) : (
sections.map((s) => (
<div key={s.id} role="group" aria-labelledby={`${uid}-g-${s.id}`} className="pb-1 [&+&]:pt-1">
<div id={`${uid}-g-${s.id}`} className="px-2.5 pb-1 pt-1.5 text-[12px] font-medium text-text-muted">
{s.label}
</div>
{s.rows.map(({ item, indices }) => {
idx += 1;
const i = idx;
const on = i === clamped;
return (
<div
key={item.id}
id={optId(i)}
role="option"
aria-selected={on}
data-index={i}
onPointerMove={() => !on && setActive(i)}
onClick={() => exec(item)}
className={twMerge(
'relative flex h-10 cursor-default select-none items-center gap-3 rounded-lg px-2.5 text-sm',
on ? 'text-text-primary' : 'text-text-secondary',
)}
>
{on && (
<motion.span
layoutId={`${uid}-hl`}
transition={reduce ? { duration: 0 } : SPRING_SNAPPY}
className="absolute inset-0 rounded-lg bg-ghost-selected"
/>
)}
{item.icon && (
<span aria-hidden className={twMerge('relative z-10 flex shrink-0 items-center [&_svg]:size-[18px]', on ? 'text-text-primary' : 'text-text-muted')}>
{item.icon}
</span>
)}
<span className="relative z-10 min-w-0 flex-1 truncate text-text-primary">
<Highlight text={item.label} indices={indices} />
{item.hint && <span className="ml-2 text-[12px] text-text-muted">{item.hint}</span>}
</span>
{item.shortcut && (
<span className="relative z-10 flex shrink-0 gap-1">
{item.shortcut.map((k, ki) => (
<Kbd key={ki}>{k}</Kbd>
))}
</span>
)}
</div>
);
})}
</div>
))
)}
</div>
<div className="flex items-center gap-4 border-t border-hairline bg-surface-1 px-4 py-2 text-[12px] text-text-muted dark:bg-transparent">
<span className="inline-flex items-center gap-1.5">
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
{t.select}
</span>
<span className="inline-flex items-center gap-1.5">
<Kbd>↵</Kbd>
{t.open}
</span>
<span className="inline-flex items-center gap-1.5">
<Kbd>esc</Kbd>
{t.esc}
</span>
</div>
</Dialog>
);
}