Command Palette
MarketingThe ⌘K launcher, hand-built. Focus is captured on open and restored on close, Tab is trapped, and the list is driven by aria-activedescendant so focus never leaves the input. Typing re-ranks commands with a FLIP animation — rows glide to their new positions instead of snapping — and a debounced async 'actions' section runs a simulated remote search with derived loading. Keyboard-first, portaled, and monochrome.
1280pxOpen
components/blocks/command-palette.tsx
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
/**
* CommandPalette — the ⌘K launcher from modern apps. Three hard parts, all
* hand-built:
*
* 1. **Focus management.** Opening captures the previously focused element and
* moves focus to the input; closing restores it. Tab is trapped inside the
* dialog, Escape closes, and a click on the backdrop dismisses. The list is
* driven by `aria-activedescendant`, so focus never leaves the input while
* you arrow through options.
* 2. **List reorder animation.** Every command stays mounted; typing re-ranks
* them (matches rise, misses dim and sink). A FLIP pass in a layout effect
* measures each row's old/new box, inverts the delta, and plays it to zero —
* so rows glide to their new positions instead of snapping. Keeping rows
* mounted sidesteps the exit-animation problem entirely.
* 3. **Debounced async.** A second "actions" section runs a simulated remote
* search: `loading` is *derived* (query ≠ last-resolved query), and a single
* debounced timer commits results — no request fires per keystroke and no
* state is set synchronously in an effect.
*
* Monochrome, keyboard-first, honors `prefers-reduced-motion`.
*/
interface Command {
id: string;
label: string;
hint: string;
}
const COMMANDS: Command[] = [
{ id: "new-file", label: "Create new file", hint: "⌘N" },
{ id: "new-project", label: "Start a new project", hint: "⇧⌘N" },
{ id: "search", label: "Search across workspace", hint: "⌘F" },
{ id: "settings", label: "Open settings", hint: "⌘," },
{ id: "theme", label: "Toggle theme", hint: "⌘⇧L" },
{ id: "invite", label: "Invite a teammate", hint: "" },
{ id: "docs", label: "Read the documentation", hint: "" },
{ id: "keyboard", label: "View keyboard shortcuts", hint: "?" },
{ id: "logout", label: "Sign out", hint: "" },
];
/** 0 = no match; higher = better. Substring beats subsequence; earlier beats later. */
function scoreOf(text: string, q: string): number {
if (!q) return 1;
const t = text.toLowerCase();
const idx = t.indexOf(q);
if (idx >= 0) return 1000 - idx;
let ti = 0;
let qi = 0;
let s = 0;
while (ti < t.length && qi < q.length) {
if (t[ti] === q[qi]) {
s += 1;
qi += 1;
}
ti += 1;
}
return qi === q.length ? s : 0;
}
const useIsoLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export function CommandPalette() {
const [open, setOpen] = React.useState(false);
const openPalette = React.useCallback(() => setOpen(true), []);
const closePalette = React.useCallback(() => setOpen(false), []);
// Global ⌘K / Ctrl-K toggles the palette.
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((o) => !o);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const mounted = React.useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
return (
<section className="grid min-h-[26rem] place-items-center bg-zinc-50 px-6 py-16 text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50">
<div className="text-center">
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Press to open the command palette
</p>
<button
type="button"
onClick={openPalette}
className="mt-3 inline-flex items-center gap-2 rounded-xl border border-zinc-200 bg-white px-4 py-2 text-sm text-zinc-950 transition-colors hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-50 dark:hover:bg-zinc-800 dark:focus-visible:ring-zinc-50/50"
>
Search commands
<kbd className="rounded border border-zinc-200 bg-zinc-50 px-1.5 py-0.5 font-mono text-xs text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400">
⌘K
</kbd>
</button>
</div>
{mounted && open
? createPortal(<Palette onClose={closePalette} />, document.body)
: null}
</section>
);
}
function Palette({ onClose }: { onClose: () => void }) {
const baseId = React.useId().replace(/[^a-zA-Z0-9]/g, "");
const inputRef = React.useRef<HTMLInputElement>(null);
const dialogRef = React.useRef<HTMLDivElement>(null);
const listRef = React.useRef<HTMLUListElement>(null);
const restoreRef = React.useRef<HTMLElement | null>(null);
const itemRefs = React.useRef<Map<string, HTMLLIElement>>(new Map());
const prevRects = React.useRef<Map<string, DOMRect>>(new Map());
const [query, setQuery] = React.useState("");
const [activeIndex, setActiveIndex] = React.useState(0);
const [shown, setShown] = React.useState(false);
const [asyncResults, setAsyncResults] = React.useState<{
q: string;
items: string[];
}>({ q: "", items: [] });
const q = query.trim().toLowerCase();
// Rank commands; matches first (by score), misses after (original order).
const { ordered, matchCount } = React.useMemo(() => {
const scored = COMMANDS.map((c, i) => ({ c, i, s: scoreOf(c.label, q) }));
scored.sort((a, b) => {
const am = a.s > 0;
const bm = b.s > 0;
if (am !== bm) return am ? -1 : 1;
if (am && a.s !== b.s) return b.s - a.s;
return a.i - b.i;
});
return {
ordered: scored,
matchCount: scored.filter((x) => x.s > 0).length,
};
}, [q]);
// Reset selection to the top whenever the ranking changes (during render).
const [prevQ, setPrevQ] = React.useState(q);
if (q !== prevQ) {
setPrevQ(q);
setActiveIndex(0);
}
const activeCmd =
matchCount > 0 ? ordered[Math.min(activeIndex, matchCount - 1)].c : null;
// --- Focus management: capture, focus input, restore on unmount. ---
useIsoLayoutEffect(() => {
restoreRef.current = document.activeElement as HTMLElement | null;
const raf = requestAnimationFrame(() => {
inputRef.current?.focus();
setShown(true);
});
return () => {
cancelAnimationFrame(raf);
restoreRef.current?.focus?.();
};
}, []);
// --- FLIP: glide rows from their previous box to the new one. ---
useIsoLayoutEffect(() => {
const reduce = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
).matches;
const items = itemRefs.current;
// Read all new boxes first (no interleaved writes → no layout thrash).
const nextRects = new Map<string, DOMRect>();
items.forEach((el, id) => nextRects.set(id, el.getBoundingClientRect()));
const moved: HTMLLIElement[] = [];
if (!reduce) {
items.forEach((el, id) => {
const prev = prevRects.current.get(id);
const next = nextRects.get(id)!;
if (!prev) return;
const dx = prev.left - next.left;
const dy = prev.top - next.top;
if (dx || dy) {
el.style.transition = "none";
el.style.transform = `translate(${dx}px, ${dy}px)`;
moved.push(el);
}
});
}
prevRects.current = nextRects;
if (moved.length && listRef.current) {
void listRef.current.offsetWidth; // one reflow to commit the inverted state
for (const el of moved) {
el.style.transition = "transform 240ms cubic-bezier(0.16, 1, 0.3, 1)";
el.style.transform = "";
}
}
}, [ordered]);
// Keep the active row visible as you arrow through.
React.useEffect(() => {
if (!activeCmd) return;
itemRefs.current.get(activeCmd.id)?.scrollIntoView({ block: "nearest" });
}, [activeCmd]);
// --- Debounced async "actions": derive loading, commit results on a timer. ---
const asyncActive = q.length >= 2;
const loading = asyncActive && asyncResults.q !== q;
React.useEffect(() => {
if (!asyncActive) return;
const t = window.setTimeout(() => {
setAsyncResults({
q,
items: [
`Search the web for “${query.trim()}”`,
`Ask the assistant about “${query.trim()}”`,
],
});
}, 450);
return () => window.clearTimeout(t);
}, [q, query, asyncActive]);
const run = (label: string) => {
// A real palette would dispatch here; we just close.
void label;
onClose();
};
const move = (dir: 1 | -1) => {
if (matchCount === 0) return;
setActiveIndex((i) => {
const cur = Math.min(i, matchCount - 1);
return (cur + dir + matchCount) % matchCount;
});
};
const onDialogKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
} else if (e.key === "ArrowDown") {
e.preventDefault();
move(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
move(-1);
} else if (e.key === "Enter") {
if (activeCmd) {
e.preventDefault();
run(activeCmd.label);
}
} else if (e.key === "Tab") {
// Only the input is tabbable — keep focus trapped inside the dialog.
e.preventDefault();
inputRef.current?.focus();
}
};
const listId = `${baseId}-list`;
const optionId = (id: string) => `${baseId}-opt-${id}`;
return (
<div
className={cn(
"fixed inset-0 z-50 flex items-start justify-center p-4 pt-[12vh]",
"bg-zinc-50/60 backdrop-blur-sm transition-opacity duration-150 dark:bg-zinc-950/60",
shown ? "opacity-100" : "opacity-0",
"motion-reduce:transition-none",
)}
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Command palette"
onKeyDown={onDialogKeyDown}
className={cn(
"w-full max-w-lg overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-900",
"transition-all duration-150 ease-[cubic-bezier(0.16,1,0.3,1)]",
shown
? "translate-y-0 scale-100 opacity-100"
: "-translate-y-1 scale-[0.98] opacity-0",
"motion-reduce:translate-y-0 motion-reduce:scale-100 motion-reduce:transition-none",
)}
>
{/* Search input */}
<div className="flex items-center gap-2.5 border-b border-zinc-200 px-4 dark:border-zinc-800">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="size-4 shrink-0 text-zinc-500 dark:text-zinc-400"
>
<circle cx={11} cy={11} r={7} />
<path d="m21 21-4.3-4.3" />
</svg>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type a command or search…"
role="combobox"
aria-expanded
aria-controls={listId}
aria-activedescendant={
activeCmd ? optionId(activeCmd.id) : undefined
}
aria-autocomplete="list"
className="h-12 w-full bg-transparent text-sm text-zinc-950 outline-none placeholder:text-zinc-500 dark:text-zinc-50 dark:placeholder:text-zinc-400"
/>
<kbd className="hidden shrink-0 rounded border border-zinc-200 bg-zinc-50 px-1.5 py-0.5 font-mono text-[10px] text-zinc-500 sm:block dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400">
ESC
</kbd>
</div>
{/* Results */}
<div className="max-h-[min(24rem,50vh)] overflow-y-auto p-2">
<ul ref={listRef} id={listId} role="listbox" aria-label="Commands">
{ordered.map(({ c, s }) => {
const isMatch = s > 0;
const isActive = activeCmd?.id === c.id;
return (
<li
key={c.id}
ref={(el) => {
const m = itemRefs.current;
if (el) m.set(c.id, el);
else m.delete(c.id);
}}
id={optionId(c.id)}
role="option"
aria-selected={isActive}
aria-disabled={!isMatch}
onMouseEnter={() => {
if (isMatch) {
const idx = ordered.findIndex((o) => o.c.id === c.id);
setActiveIndex(idx);
}
}}
onClick={() => isMatch && run(c.label)}
className={cn(
"flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 text-sm",
isMatch
? "text-zinc-950 dark:text-zinc-50"
: "pointer-events-none opacity-35",
isActive && "bg-zinc-100 dark:bg-zinc-800",
)}
>
<span className="truncate">{c.label}</span>
{c.hint ? (
<kbd className="ml-3 shrink-0 font-mono text-xs text-zinc-500 dark:text-zinc-400">
{c.hint}
</kbd>
) : null}
</li>
);
})}
</ul>
{matchCount === 0 && !asyncActive ? (
<p className="px-3 py-6 text-center text-sm text-zinc-500 dark:text-zinc-400">
No commands found.
</p>
) : null}
{/* Debounced async section */}
{asyncActive ? (
<div className="mt-2 border-t border-zinc-200 pt-2 dark:border-zinc-800">
<div className="px-3 pb-1 font-mono text-[11px] tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
Actions
</div>
{loading ? (
<div className="flex items-center gap-2.5 px-3 py-2.5 text-sm text-zinc-500 dark:text-zinc-400">
<span
aria-hidden="true"
className="size-3.5 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-950 motion-reduce:animate-none dark:border-zinc-800 dark:border-t-zinc-50"
/>
Searching…
</div>
) : (
asyncResults.items.map((label) => (
<button
key={label}
type="button"
tabIndex={-1}
onClick={() => run(label)}
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-zinc-950 hover:bg-zinc-100 dark:text-zinc-50 dark:hover:bg-zinc-800"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="size-4 shrink-0 text-zinc-500 dark:text-zinc-400"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
<span className="truncate">{label}</span>
</button>
))
)}
</div>
) : null}
</div>
</div>
</div>
);
}Installation
Terminal
npx shadcn@latest add https://ui.saumyarex.xyz/r/command-palette.jsonInstalls the block and its component dependencies in one step.
Install dependencies
Terminal
npm install clsx tailwind-mergeCopy the source
components/blocks/command-palette.tsx
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
/**
* CommandPalette — the ⌘K launcher from modern apps. Three hard parts, all
* hand-built:
*
* 1. **Focus management.** Opening captures the previously focused element and
* moves focus to the input; closing restores it. Tab is trapped inside the
* dialog, Escape closes, and a click on the backdrop dismisses. The list is
* driven by `aria-activedescendant`, so focus never leaves the input while
* you arrow through options.
* 2. **List reorder animation.** Every command stays mounted; typing re-ranks
* them (matches rise, misses dim and sink). A FLIP pass in a layout effect
* measures each row's old/new box, inverts the delta, and plays it to zero —
* so rows glide to their new positions instead of snapping. Keeping rows
* mounted sidesteps the exit-animation problem entirely.
* 3. **Debounced async.** A second "actions" section runs a simulated remote
* search: `loading` is *derived* (query ≠ last-resolved query), and a single
* debounced timer commits results — no request fires per keystroke and no
* state is set synchronously in an effect.
*
* Monochrome, keyboard-first, honors `prefers-reduced-motion`.
*/
interface Command {
id: string;
label: string;
hint: string;
}
const COMMANDS: Command[] = [
{ id: "new-file", label: "Create new file", hint: "⌘N" },
{ id: "new-project", label: "Start a new project", hint: "⇧⌘N" },
{ id: "search", label: "Search across workspace", hint: "⌘F" },
{ id: "settings", label: "Open settings", hint: "⌘," },
{ id: "theme", label: "Toggle theme", hint: "⌘⇧L" },
{ id: "invite", label: "Invite a teammate", hint: "" },
{ id: "docs", label: "Read the documentation", hint: "" },
{ id: "keyboard", label: "View keyboard shortcuts", hint: "?" },
{ id: "logout", label: "Sign out", hint: "" },
];
/** 0 = no match; higher = better. Substring beats subsequence; earlier beats later. */
function scoreOf(text: string, q: string): number {
if (!q) return 1;
const t = text.toLowerCase();
const idx = t.indexOf(q);
if (idx >= 0) return 1000 - idx;
let ti = 0;
let qi = 0;
let s = 0;
while (ti < t.length && qi < q.length) {
if (t[ti] === q[qi]) {
s += 1;
qi += 1;
}
ti += 1;
}
return qi === q.length ? s : 0;
}
const useIsoLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export function CommandPalette() {
const [open, setOpen] = React.useState(false);
const openPalette = React.useCallback(() => setOpen(true), []);
const closePalette = React.useCallback(() => setOpen(false), []);
// Global ⌘K / Ctrl-K toggles the palette.
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((o) => !o);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const mounted = React.useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
return (
<section className="grid min-h-[26rem] place-items-center bg-zinc-50 px-6 py-16 text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50">
<div className="text-center">
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Press to open the command palette
</p>
<button
type="button"
onClick={openPalette}
className="mt-3 inline-flex items-center gap-2 rounded-xl border border-zinc-200 bg-white px-4 py-2 text-sm text-zinc-950 transition-colors hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-50 dark:hover:bg-zinc-800 dark:focus-visible:ring-zinc-50/50"
>
Search commands
<kbd className="rounded border border-zinc-200 bg-zinc-50 px-1.5 py-0.5 font-mono text-xs text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400">
⌘K
</kbd>
</button>
</div>
{mounted && open
? createPortal(<Palette onClose={closePalette} />, document.body)
: null}
</section>
);
}
function Palette({ onClose }: { onClose: () => void }) {
const baseId = React.useId().replace(/[^a-zA-Z0-9]/g, "");
const inputRef = React.useRef<HTMLInputElement>(null);
const dialogRef = React.useRef<HTMLDivElement>(null);
const listRef = React.useRef<HTMLUListElement>(null);
const restoreRef = React.useRef<HTMLElement | null>(null);
const itemRefs = React.useRef<Map<string, HTMLLIElement>>(new Map());
const prevRects = React.useRef<Map<string, DOMRect>>(new Map());
const [query, setQuery] = React.useState("");
const [activeIndex, setActiveIndex] = React.useState(0);
const [shown, setShown] = React.useState(false);
const [asyncResults, setAsyncResults] = React.useState<{
q: string;
items: string[];
}>({ q: "", items: [] });
const q = query.trim().toLowerCase();
// Rank commands; matches first (by score), misses after (original order).
const { ordered, matchCount } = React.useMemo(() => {
const scored = COMMANDS.map((c, i) => ({ c, i, s: scoreOf(c.label, q) }));
scored.sort((a, b) => {
const am = a.s > 0;
const bm = b.s > 0;
if (am !== bm) return am ? -1 : 1;
if (am && a.s !== b.s) return b.s - a.s;
return a.i - b.i;
});
return {
ordered: scored,
matchCount: scored.filter((x) => x.s > 0).length,
};
}, [q]);
// Reset selection to the top whenever the ranking changes (during render).
const [prevQ, setPrevQ] = React.useState(q);
if (q !== prevQ) {
setPrevQ(q);
setActiveIndex(0);
}
const activeCmd =
matchCount > 0 ? ordered[Math.min(activeIndex, matchCount - 1)].c : null;
// --- Focus management: capture, focus input, restore on unmount. ---
useIsoLayoutEffect(() => {
restoreRef.current = document.activeElement as HTMLElement | null;
const raf = requestAnimationFrame(() => {
inputRef.current?.focus();
setShown(true);
});
return () => {
cancelAnimationFrame(raf);
restoreRef.current?.focus?.();
};
}, []);
// --- FLIP: glide rows from their previous box to the new one. ---
useIsoLayoutEffect(() => {
const reduce = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
).matches;
const items = itemRefs.current;
// Read all new boxes first (no interleaved writes → no layout thrash).
const nextRects = new Map<string, DOMRect>();
items.forEach((el, id) => nextRects.set(id, el.getBoundingClientRect()));
const moved: HTMLLIElement[] = [];
if (!reduce) {
items.forEach((el, id) => {
const prev = prevRects.current.get(id);
const next = nextRects.get(id)!;
if (!prev) return;
const dx = prev.left - next.left;
const dy = prev.top - next.top;
if (dx || dy) {
el.style.transition = "none";
el.style.transform = `translate(${dx}px, ${dy}px)`;
moved.push(el);
}
});
}
prevRects.current = nextRects;
if (moved.length && listRef.current) {
void listRef.current.offsetWidth; // one reflow to commit the inverted state
for (const el of moved) {
el.style.transition = "transform 240ms cubic-bezier(0.16, 1, 0.3, 1)";
el.style.transform = "";
}
}
}, [ordered]);
// Keep the active row visible as you arrow through.
React.useEffect(() => {
if (!activeCmd) return;
itemRefs.current.get(activeCmd.id)?.scrollIntoView({ block: "nearest" });
}, [activeCmd]);
// --- Debounced async "actions": derive loading, commit results on a timer. ---
const asyncActive = q.length >= 2;
const loading = asyncActive && asyncResults.q !== q;
React.useEffect(() => {
if (!asyncActive) return;
const t = window.setTimeout(() => {
setAsyncResults({
q,
items: [
`Search the web for “${query.trim()}”`,
`Ask the assistant about “${query.trim()}”`,
],
});
}, 450);
return () => window.clearTimeout(t);
}, [q, query, asyncActive]);
const run = (label: string) => {
// A real palette would dispatch here; we just close.
void label;
onClose();
};
const move = (dir: 1 | -1) => {
if (matchCount === 0) return;
setActiveIndex((i) => {
const cur = Math.min(i, matchCount - 1);
return (cur + dir + matchCount) % matchCount;
});
};
const onDialogKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
} else if (e.key === "ArrowDown") {
e.preventDefault();
move(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
move(-1);
} else if (e.key === "Enter") {
if (activeCmd) {
e.preventDefault();
run(activeCmd.label);
}
} else if (e.key === "Tab") {
// Only the input is tabbable — keep focus trapped inside the dialog.
e.preventDefault();
inputRef.current?.focus();
}
};
const listId = `${baseId}-list`;
const optionId = (id: string) => `${baseId}-opt-${id}`;
return (
<div
className={cn(
"fixed inset-0 z-50 flex items-start justify-center p-4 pt-[12vh]",
"bg-zinc-50/60 backdrop-blur-sm transition-opacity duration-150 dark:bg-zinc-950/60",
shown ? "opacity-100" : "opacity-0",
"motion-reduce:transition-none",
)}
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Command palette"
onKeyDown={onDialogKeyDown}
className={cn(
"w-full max-w-lg overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-900",
"transition-all duration-150 ease-[cubic-bezier(0.16,1,0.3,1)]",
shown
? "translate-y-0 scale-100 opacity-100"
: "-translate-y-1 scale-[0.98] opacity-0",
"motion-reduce:translate-y-0 motion-reduce:scale-100 motion-reduce:transition-none",
)}
>
{/* Search input */}
<div className="flex items-center gap-2.5 border-b border-zinc-200 px-4 dark:border-zinc-800">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="size-4 shrink-0 text-zinc-500 dark:text-zinc-400"
>
<circle cx={11} cy={11} r={7} />
<path d="m21 21-4.3-4.3" />
</svg>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type a command or search…"
role="combobox"
aria-expanded
aria-controls={listId}
aria-activedescendant={
activeCmd ? optionId(activeCmd.id) : undefined
}
aria-autocomplete="list"
className="h-12 w-full bg-transparent text-sm text-zinc-950 outline-none placeholder:text-zinc-500 dark:text-zinc-50 dark:placeholder:text-zinc-400"
/>
<kbd className="hidden shrink-0 rounded border border-zinc-200 bg-zinc-50 px-1.5 py-0.5 font-mono text-[10px] text-zinc-500 sm:block dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400">
ESC
</kbd>
</div>
{/* Results */}
<div className="max-h-[min(24rem,50vh)] overflow-y-auto p-2">
<ul ref={listRef} id={listId} role="listbox" aria-label="Commands">
{ordered.map(({ c, s }) => {
const isMatch = s > 0;
const isActive = activeCmd?.id === c.id;
return (
<li
key={c.id}
ref={(el) => {
const m = itemRefs.current;
if (el) m.set(c.id, el);
else m.delete(c.id);
}}
id={optionId(c.id)}
role="option"
aria-selected={isActive}
aria-disabled={!isMatch}
onMouseEnter={() => {
if (isMatch) {
const idx = ordered.findIndex((o) => o.c.id === c.id);
setActiveIndex(idx);
}
}}
onClick={() => isMatch && run(c.label)}
className={cn(
"flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 text-sm",
isMatch
? "text-zinc-950 dark:text-zinc-50"
: "pointer-events-none opacity-35",
isActive && "bg-zinc-100 dark:bg-zinc-800",
)}
>
<span className="truncate">{c.label}</span>
{c.hint ? (
<kbd className="ml-3 shrink-0 font-mono text-xs text-zinc-500 dark:text-zinc-400">
{c.hint}
</kbd>
) : null}
</li>
);
})}
</ul>
{matchCount === 0 && !asyncActive ? (
<p className="px-3 py-6 text-center text-sm text-zinc-500 dark:text-zinc-400">
No commands found.
</p>
) : null}
{/* Debounced async section */}
{asyncActive ? (
<div className="mt-2 border-t border-zinc-200 pt-2 dark:border-zinc-800">
<div className="px-3 pb-1 font-mono text-[11px] tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
Actions
</div>
{loading ? (
<div className="flex items-center gap-2.5 px-3 py-2.5 text-sm text-zinc-500 dark:text-zinc-400">
<span
aria-hidden="true"
className="size-3.5 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-950 motion-reduce:animate-none dark:border-zinc-800 dark:border-t-zinc-50"
/>
Searching…
</div>
) : (
asyncResults.items.map((label) => (
<button
key={label}
type="button"
tabIndex={-1}
onClick={() => run(label)}
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-zinc-950 hover:bg-zinc-100 dark:text-zinc-50 dark:hover:bg-zinc-800"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="size-4 shrink-0 text-zinc-500 dark:text-zinc-400"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
<span className="truncate">{label}</span>
</button>
))
)}
</div>
) : null}
</div>
</div>
</div>
);
}lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}