"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronRight, CornerDownLeft, Loader2, Search, SearchX } from "lucide-react";
import { cn } from "@/lib/utils";
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
export type CommandIcon = React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
export type CommandPage = {
id: string;
title: string;
placeholder?: string;
groups: CommandGroup[];
};
export type CommandItem = {
id: string;
label: string;
icon?: CommandIcon;
/** Secondary text shown on the right (e.g. a path or a type). */
hint?: string;
/** Keys shown as keycaps, e.g. ["⌘", "B"]. */
shortcut?: string[];
/** Extra words that should match the search (not highlighted). */
keywords?: string[];
/** Opening this item pushes a nested page instead of running it. */
page?: CommandPage;
onSelect?: () => void;
disabled?: boolean;
};
export type CommandGroup = { id: string; heading: string; items: CommandItem[] };
export interface CommandPaletteProps {
groups?: CommandGroup[];
/** Simulated or real async search, merged below local results. */
asyncSearch?: (query: string, signal: AbortSignal) => Promise<CommandItem[]>;
asyncHeading?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Called for every executed (non-page) item. */
onSelect?: (item: CommandItem) => void;
/** Letter used with ⌘/Ctrl to toggle the palette. `null` disables the hotkey. */
hotkey?: string | null;
placeholder?: string;
/** Renders the "Search…" trigger button. */
showTrigger?: boolean;
recentLimit?: number;
/** Item ids shown under "Recent" before anything was run. */
defaultRecent?: string[];
/** localStorage key for recents (best effort). */
storageKey?: string;
className?: string;
}
/* ----------------------------------------------------------------------------
* Fuzzy matching
* ------------------------------------------------------------------------- */
type Match = { score: number; indices: number[] };
export function fuzzyMatch(query: string, text: string): Match | null {
const q = query.toLowerCase().replace(/\s+/g, "");
if (!q) return { score: 0, indices: [] };
const t = text.toLowerCase();
// Contiguous substring wins big (prefer word starts)
const sub = t.indexOf(q);
if (sub !== -1) {
const atWord = sub === 0 || /[\s\-_/.]/.test(t[sub - 1]);
return {
score: 100 + (atWord ? 40 : 0) - sub - (t.length - q.length) * 0.2,
indices: Array.from({ length: q.length }, (_, i) => sub + i),
};
}
// Subsequence: prefer word-boundary hits, reward runs
const indices: number[] = [];
let score = 0;
let from = 0;
for (let i = 0; i < q.length; i++) {
const ch = q[i];
let at = -1;
for (let j = from; j < t.length; j++) {
if (t[j] !== ch) continue;
const boundary = j === 0 || /[\s\-_/.]/.test(t[j - 1]);
if (boundary) {
at = j;
break;
}
if (at === -1) at = j;
}
if (at === -1) return null;
const prev = indices[indices.length - 1];
const boundary = at === 0 || /[\s\-_/.]/.test(t[at - 1]);
score += 1 + (boundary ? 8 : 0) + (prev !== undefined && at === prev + 1 ? 5 : 0) - Math.min(4, at - from) * 0.5;
indices.push(at);
from = at + 1;
}
return { score, indices };
}
function Highlight({ text, indices }: { text: string; indices: number[] }) {
if (!indices.length) return <>{text}</>;
const set = new Set(indices);
const out: React.ReactNode[] = [];
let buf = "";
let on = false;
const flush = (k: number) => {
if (!buf) return;
out.push(
on ? (
<mark key={k} className="rounded-[3px] bg-primary/12 px-px font-semibold text-foreground dark:bg-primary/25">
{buf}
</mark>
) : (
<span key={k}>{buf}</span>
),
);
buf = "";
};
for (let i = 0; i < text.length; i++) {
const hit = set.has(i);
if (hit !== on) {
flush(i);
on = hit;
}
buf += text[i];
}
flush(text.length);
return <>{out}</>;
}
/* ----------------------------------------------------------------------------
* Helpers
* ------------------------------------------------------------------------- */
const noopSubscribe = () => () => {};
function useIsMac() {
return React.useSyncExternalStore(
noopSubscribe,
() => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent),
() => true,
);
}
function Kbd({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<kbd
className={cn(
"inline-flex h-5 min-w-5 items-center justify-center rounded-md border border-b-2 bg-background px-1 font-sans text-[10.5px] font-medium text-muted-foreground",
className,
)}
>
{children}
</kbd>
);
}
type Row = { item: CommandItem; group: string; match: Match };
type ResultGroup = { id: string; heading: string; rows: Row[]; loading?: boolean };
/* ----------------------------------------------------------------------------
* Component
* ------------------------------------------------------------------------- */
export function CommandPalette({
groups = [],
asyncSearch,
asyncHeading = "Search results",
open: openProp,
defaultOpen = false,
onOpenChange,
onSelect,
hotkey = "k",
placeholder = "Type a command or search…",
showTrigger = true,
recentLimit = 3,
defaultRecent = [],
storageKey,
className,
}: CommandPaletteProps) {
const uid = React.useId();
const reduce = useReducedMotion();
const isMac = useIsMac();
const [openInner, setOpenInner] = React.useState(defaultOpen);
const open = openProp ?? openInner;
const setOpen = React.useCallback(
(v: boolean) => {
if (openProp === undefined) setOpenInner(v);
onOpenChange?.(v);
},
[openProp, onOpenChange],
);
const [pages, setPages] = React.useState<CommandPage[]>([]);
const [pageDir, setPageDir] = React.useState(1);
const [query, setQuery] = React.useState("");
const [activeId, setActiveId] = React.useState<string | null>(null);
const [recent, setRecent] = React.useState<string[]>(defaultRecent);
/* restore persisted recents after mount (keeps SSR markup stable) */
React.useEffect(() => {
if (!storageKey) return;
try {
const saved = JSON.parse(localStorage.getItem(storageKey) ?? "null") as unknown;
if (Array.isArray(saved)) setRecent(saved.filter((x): x is string => typeof x === "string"));
} catch {
/* storage unavailable */
}
}, [storageKey]);
const [asyncResult, setAsyncResult] = React.useState<{ query: string; items: CommandItem[] } | null>(null);
const [pulse, setPulse] = React.useState<string | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const restoreRef = React.useRef<HTMLElement | null>(null);
const page = pages[pages.length - 1];
const sourceGroups = page ? page.groups : groups;
const trimmed = query.trim();
const asyncOn = !!asyncSearch && !page && trimmed.length >= 2;
/* all items reachable from root (for recents lookup) */
const byId = React.useMemo(() => {
const m = new Map<string, CommandItem>();
const walk = (gs: CommandGroup[]) =>
gs.forEach((g) =>
g.items.forEach((it) => {
m.set(it.id, it);
if (it.page) walk(it.page.groups);
}),
);
walk(groups);
return m;
}, [groups]);
/* results */
const results: ResultGroup[] = React.useMemo(() => {
const out: ResultGroup[] = [];
if (!trimmed && !page && recent.length) {
const rows = recent
.map((id) => byId.get(id))
.filter((x): x is CommandItem => !!x)
.slice(0, recentLimit)
.map((item) => ({ item, group: "recent", match: { score: 0, indices: [] } }));
if (rows.length) out.push({ id: "recent", heading: "Recent", rows });
}
for (const g of sourceGroups) {
const rows: Row[] = [];
for (const item of g.items) {
if (!trimmed) {
rows.push({ item, group: g.id, match: { score: 0, indices: [] } });
continue;
}
const m = fuzzyMatch(trimmed, item.label);
const kw = !m && item.keywords?.some((k) => fuzzyMatch(trimmed, k));
if (m) rows.push({ item, group: g.id, match: m });
else if (kw) rows.push({ item, group: g.id, match: { score: 1, indices: [] } });
}
if (trimmed) rows.sort((a, b) => b.match.score - a.match.score);
if (rows.length) out.push({ id: g.id, heading: g.heading, rows });
}
if (trimmed) out.sort((a, b) => (b.rows[0]?.match.score ?? 0) - (a.rows[0]?.match.score ?? 0));
if (asyncOn) {
const ready = asyncResult?.query === trimmed;
out.push({
id: "async",
heading: asyncHeading,
loading: !ready,
rows: ready
? asyncResult.items.map((item) => ({ item, group: "async", match: fuzzyMatch(trimmed, item.label) ?? { score: 0, indices: [] } }))
: [],
});
}
return out;
}, [trimmed, page, recent, byId, recentLimit, sourceGroups, asyncOn, asyncResult, asyncHeading]);
const flat = React.useMemo(() => results.flatMap((g) => g.rows.filter((r) => !r.item.disabled)), [results]);
const rowKey = (r: Row) => `${r.group}:${r.item.id}`;
const active = flat.find((r) => rowKey(r) === activeId) ?? flat[0];
const activeKey = active ? rowKey(active) : null;
const optionId = (key: string) => `${uid}-opt-${key.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
const hasAnyRows = results.some((g) => g.rows.length);
const loadingAsync = results.some((g) => g.loading);
/* async search (state only written from async callbacks) */
React.useEffect(() => {
if (!asyncOn || !asyncSearch) return;
const ctrl = new AbortController();
const t = window.setTimeout(() => {
asyncSearch(trimmed, ctrl.signal)
.then((items) => {
if (!ctrl.signal.aborted) setAsyncResult({ query: trimmed, items });
})
.catch(() => {
if (!ctrl.signal.aborted) setAsyncResult({ query: trimmed, items: [] });
});
}, 220);
return () => {
ctrl.abort();
window.clearTimeout(t);
};
}, [asyncOn, asyncSearch, trimmed]);
/* global hotkey */
React.useEffect(() => {
if (!hotkey) return;
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === hotkey.toLowerCase()) {
e.preventDefault();
if (!open) {
restoreRef.current = document.activeElement as HTMLElement | null;
setPages([]);
setQuery("");
setActiveId(null);
}
setOpen(!open);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [hotkey, open, setOpen]);
/* focus management + scroll lock */
React.useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const id = window.requestAnimationFrame(() => inputRef.current?.focus());
return () => {
window.cancelAnimationFrame(id);
document.body.style.overflow = prev;
};
}, [open]);
/* keep active option visible */
React.useEffect(() => {
if (!activeKey) return;
document.getElementById(optionId(activeKey))?.scrollIntoView({ block: "nearest" });
// eslint-disable-next-line react-hooks/exhaustive-deps -- optionId is derived from uid
}, [activeKey]);
const show = () => {
restoreRef.current = document.activeElement as HTMLElement | null;
setPages([]);
setQuery("");
setActiveId(null);
setOpen(true);
};
const hide = () => {
setOpen(false);
const el = restoreRef.current;
window.setTimeout(() => el?.focus?.(), 0);
};
const pushPage = (p: CommandPage) => {
setPageDir(1);
setPages((s) => [...s, p]);
setQuery("");
setActiveId(null);
};
const popPage = () => {
setPageDir(-1);
setPages((s) => s.slice(0, -1));
setQuery("");
setActiveId(null);
};
const run = (row: Row) => {
const { item } = row;
if (item.disabled) return;
if (item.page) return pushPage(item.page);
setPulse(rowKey(row));
setRecent((r) => {
const next = [item.id, ...r.filter((x) => x !== item.id)].slice(0, 8);
if (storageKey) {
try {
localStorage.setItem(storageKey, JSON.stringify(next));
} catch {
/* storage unavailable */
}
}
return next;
});
item.onSelect?.();
onSelect?.(item);
window.setTimeout(() => {
setPulse(null);
hide();
}, reduce ? 0 : 140);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const i = active ? flat.indexOf(active) : -1;
const go = (n: number) => {
if (!flat.length) return;
setActiveId(rowKey(flat[(n + flat.length) % flat.length]));
};
switch (e.key) {
case "ArrowDown":
e.preventDefault();
go(i + 1);
break;
case "ArrowUp":
e.preventDefault();
go(i - 1);
break;
case "Home":
if (!query) {
e.preventDefault();
go(0);
}
break;
case "End":
if (!query) {
e.preventDefault();
go(flat.length - 1);
}
break;
case "PageDown":
e.preventDefault();
go(Math.min(flat.length - 1, i + 5));
break;
case "PageUp":
e.preventDefault();
go(Math.max(0, i - 5));
break;
case "Enter":
e.preventDefault();
if (active) run(active);
break;
case "ArrowRight":
if (active?.item.page && e.currentTarget.selectionStart === query.length) {
e.preventDefault();
pushPage(active.item.page);
}
break;
case "Backspace":
if (!query && pages.length) {
e.preventDefault();
popPage();
}
break;
case "Escape":
e.preventDefault();
e.stopPropagation();
if (query) setQuery("");
else if (pages.length) popPage();
else hide();
break;
case "Tab":
e.preventDefault(); // focus stays in the palette (options are driven by aria-activedescendant)
break;
}
};
const mod = isMac ? "⌘" : "Ctrl";
return (
<>
{showTrigger && (
<button
type="button"
onClick={show}
aria-haspopup="dialog"
aria-expanded={open}
className={cn(
"group inline-flex h-10 w-full max-w-xs items-center gap-2 rounded-xl border bg-background pl-3 pr-2 text-sm text-muted-foreground shadow-xs outline-none transition hover:border-foreground/20 hover:text-foreground focus-visible:ring-4 focus-visible:ring-ring/20",
className,
)}
>
<Search className="size-4" aria-hidden />
<span className="flex-1 text-left">Search or jump to…</span>
<span className="flex gap-1" aria-hidden>
<Kbd>{mod}</Kbd>
{hotkey && <Kbd>{hotkey.toUpperCase()}</Kbd>}
</span>
</button>
)}
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50" onKeyDown={(e) => e.key === "Escape" && hide()}>
<motion.div
aria-hidden
className="absolute inset-0 bg-background/60 backdrop-blur-[3px] dark:bg-black/55"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={hide}
/>
<motion.div
role="dialog"
aria-modal="true"
aria-label="Command palette"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: -8, transition: { duration: 0.14 } }}
transition={{ type: "spring", stiffness: 480, damping: 34 }}
className="absolute inset-x-0 top-[max(1rem,10vh)] mx-auto flex max-h-[min(560px,calc(100dvh-max(1rem,10vh)-1rem))] w-[min(640px,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-2xl shadow-black/15 ring-1 ring-black/5 dark:shadow-black/60 dark:ring-white/5"
>
{/* input row */}
<div className="flex items-center gap-2 border-b px-4">
{loadingAsync ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : (
<Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
)}
<AnimatePresence initial={false}>
{pages.map((p) => (
<motion.button
key={p.id}
type="button"
tabIndex={-1}
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={popPage}
className="shrink-0 rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-foreground"
aria-label={`${p.title} (press Backspace to go back)`}
>
{p.title}
</motion.button>
))}
</AnimatePresence>
<input
ref={inputRef}
role="combobox"
aria-expanded
aria-controls={`${uid}-list`}
aria-autocomplete="list"
aria-activedescendant={activeKey ? optionId(activeKey) : undefined}
aria-label={page ? page.title : "Search commands"}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActiveId(null);
}}
onKeyDown={onKeyDown}
placeholder={page?.placeholder ?? placeholder}
spellCheck={false}
autoComplete="off"
className="h-14 min-w-0 flex-1 bg-transparent text-[15px] text-foreground outline-none placeholder:text-muted-foreground/70"
/>
<Kbd className="hidden sm:inline-flex">esc</Kbd>
</div>
{/* results */}
<div ref={listRef} id={`${uid}-list`} role="listbox" tabIndex={-1} aria-label="Commands" className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain p-2 [scrollbar-width:thin]">
<AnimatePresence mode="popLayout" initial={false} custom={pageDir}>
<motion.div
key={page?.id ?? "root"}
custom={pageDir}
variants={{
enter: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * 24 }),
center: { opacity: 1, x: 0 },
exit: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * -24 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ type: "spring", stiffness: 500, damping: 40 }}
>
{results.map((g) => (
<div key={g.id} role="group" aria-labelledby={`${uid}-g-${g.id}`} className="mb-1 last:mb-0">
<div id={`${uid}-g-${g.id}`} className="px-2.5 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">
{g.heading}
</div>
{g.loading &&
[0, 1].map((i) => (
<div key={i} className="flex h-10 items-center gap-3 px-2.5" aria-hidden>
<span className="size-5 animate-pulse rounded-md bg-muted" />
<span className="h-3 animate-pulse rounded bg-muted" style={{ width: `${48 - i * 14}%` }} />
</div>
))}
{g.rows.map((row) => {
const k = rowKey(row);
const isActive = k === activeKey;
const Icon = row.item.icon;
return (
<div
key={k}
id={optionId(k)}
role="option"
aria-selected={isActive}
aria-disabled={row.item.disabled || undefined}
onPointerMove={() => !row.item.disabled && activeId !== k && setActiveId(k)}
onClick={() => run(row)}
className={cn(
"relative flex h-10 cursor-pointer select-none items-center gap-3 rounded-lg px-2.5 text-sm",
row.item.disabled ? "cursor-not-allowed opacity-40" : isActive ? "text-foreground" : "text-foreground/80",
)}
>
{isActive && (
<motion.span
layoutId={`${uid}-active`}
className="absolute inset-0 rounded-lg bg-accent dark:bg-accent/70"
transition={{ type: "spring", stiffness: 700, damping: 45 }}
/>
)}
{pulse === k && (
<motion.span
className="absolute inset-0 rounded-lg bg-primary/20"
initial={{ opacity: 1, scale: 0.98 }}
animate={{ opacity: 0, scale: 1.02 }}
transition={{ duration: 0.3 }}
/>
)}
<span className="relative grid size-6 shrink-0 place-items-center rounded-md border bg-background text-muted-foreground shadow-xs">
{Icon ? <Icon className="size-3.5" aria-hidden /> : <Search className="size-3.5" aria-hidden />}
</span>
<span className="relative min-w-0 flex-1 truncate">
<Highlight text={row.item.label} indices={row.match.indices} />
</span>
{row.item.hint && <span className="relative hidden truncate text-xs text-muted-foreground sm:block">{row.item.hint}</span>}
{row.item.shortcut && (
<span className="relative hidden gap-1 sm:flex" aria-label={`Shortcut ${row.item.shortcut.join(" ")}`}>
{row.item.shortcut.map((s) => (
<Kbd key={s}>{s}</Kbd>
))}
</span>
)}
{row.item.page && <ChevronRight className="relative size-4 text-muted-foreground" aria-hidden />}
{isActive && !row.item.page && (
<CornerDownLeft className="relative size-3.5 text-muted-foreground" aria-hidden />
)}
</div>
);
})}
</div>
))}
{!hasAnyRows && !loadingAsync && (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center gap-2 px-6 py-12 text-center"
role="status"
>
<span className="grid size-11 place-items-center rounded-2xl border bg-muted/60">
<SearchX className="size-5 text-muted-foreground" aria-hidden />
</span>
<p className="text-sm font-medium">No results for “{trimmed}”</p>
<p className="max-w-xs text-xs text-muted-foreground">Try a different spelling, or fewer letters — matching is fuzzy, so “nwpr” finds “New project”.</p>
</motion.div>
)}
</motion.div>
</AnimatePresence>
</div>
{/* footer */}
<div className="flex items-center gap-4 border-t bg-muted/40 px-4 py-2 text-[11px] text-muted-foreground">
<span className="flex items-center gap-1.5">
<Kbd>↑</Kbd>
<Kbd>↓</Kbd> navigate
</span>
<span className="flex items-center gap-1.5">
<Kbd>↵</Kbd> {active?.item.page ? "open" : "select"}
</span>
<span className="ml-auto hidden items-center gap-1.5 sm:flex">
{pages.length ? (
<>
<Kbd>⌫</Kbd> back
</>
) : (
<>
<Kbd>esc</Kbd> close
</>
)}
</span>
<span className="sr-only" aria-live="polite">
{loadingAsync ? "Searching…" : trimmed ? `${flat.length} results` : ""}
</span>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
}