A ⌘K palette with live filtering, grouped results and a highlight that glides between them.
Customize
Usage
import { CommandMenu } from "@/components/motionkit/command-menu";
<CommandMenu shortcut="k"
items={[
{ id: "new", label: "New project", group: "Create", hint: "⌘N", onSelect: createProject },
{ id: "deploy", label: "Deploy to production", group: "Actions", keywords: ["ship"], onSelect: deploy },
{ id: "settings", label: "Open settings", group: "Navigate", onSelect: () => router.push("/settings") },
]}
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Dialog in
Opened
Highlight glide
Arrow keys or hover
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/command-menu.tsx.
"use client";
import { useEffect, useId, useRef, useState, useSyncExternalStore, type KeyboardEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type CommandItem = {
id: string;
label: string;
group?: string;
icon?: ReactNode;
/** Shown on the right, e.g. "⌘N". */
hint?: string;
/** Extra words that should match a search. */
keywords?: string[];
onSelect?: () => void;
};
type CommandMenuProps = {
items: CommandItem[];
/** Letter that opens the menu with ⌘ (Mac) or Ctrl. Leave unset for no shortcut. */
shortcut?: string;
placeholder?: string;
/** Text on the trigger button. */
triggerLabel?: string;
className?: string;
};
// true only in the browser, after hydration, so the portal never mismatches server HTML
const noop = () => () => {};
const useIsClient = () => useSyncExternalStore(noop, () => true, () => false);
export function CommandMenu({
items,
shortcut,
placeholder = "Type a command or search…",
triggerLabel = "Search",
className = "",
}: CommandMenuProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const trigger = useRef<HTMLButtonElement>(null);
const reduceMotion = useReducedMotion();
const listId = useId();
const highlightId = useId();
const isClient = useIsClient();
const search = query.trim().toLowerCase();
const results = items.filter((item) =>
[item.label, item.group, ...(item.keywords ?? [])].join(" ").toLowerCase().includes(search),
);
const current = Math.min(active, Math.max(results.length - 1, 0));
useEffect(() => {
if (!shortcut) return;
const onKey = (event: globalThis.KeyboardEvent) => {
// another palette on the page already took this shortcut, or this one sits in an inert preview
if (event.defaultPrevented || trigger.current?.closest("[inert]")) return;
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === shortcut.toLowerCase()) {
event.preventDefault();
setOpen((value) => !value);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [shortcut]);
function close() {
setOpen(false);
setQuery("");
setActive(0);
trigger.current?.focus();
}
function choose(item: CommandItem | undefined) {
if (!item) return;
close();
item.onSelect?.();
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "ArrowDown") setActive((current + 1) % Math.max(results.length, 1));
else if (event.key === "ArrowUp") setActive((current - 1 + results.length) % Math.max(results.length, 1));
else if (event.key === "Enter") choose(results[current]);
else if (event.key === "Escape") close();
// the input is the only focusable thing inside, so Tab stays put
else if (event.key !== "Tab") return;
event.preventDefault();
}
const shortcutLabel = shortcut ? `⌘${shortcut.toUpperCase()}` : null;
return (
<>
<button
ref={trigger}
type="button"
aria-haspopup="dialog"
onClick={() => setOpen(true)}
className={`inline-flex items-center gap-3 rounded-xl border border-white/10 bg-[#141B1B] px-3.5 py-2.5 text-sm text-[#94A9A7] transition-colors hover:text-[#F6F1E8] focus-visible:outline-2 focus-visible:outline-[#5FAEB4] ${className}`}
>
<SearchIcon />
{triggerLabel}
{shortcutLabel && (
<kbd className="rounded-md border border-white/10 px-1.5 py-0.5 font-sans text-[11px] text-[#94A9A7]">{shortcutLabel}</kbd>
)}
</button>
{/* portalled to <body> so transformed ancestors can't trap the fixed overlay */}
{isClient &&
createPortal(
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[15vh] backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onMouseDown={(event) => event.target === event.currentTarget && close()}
>
<motion.div
role="dialog"
aria-modal="true"
aria-label="Command menu"
className="w-full max-w-lg overflow-hidden rounded-2xl border border-white/10 bg-[#141B1B] text-[#F6F1E8] shadow-2xl shadow-black/60"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.98, y: -4 }}
transition={reduceMotion ? { duration: 0.1 } : { type: "spring", bounce: 0.15, duration: 0.3 }}
>
<div className="flex items-center gap-3 border-b border-white/10 px-4">
<SearchIcon />
<input
autoFocus
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-activedescendant={results[current] ? `${listId}-${results[current].id}` : undefined}
aria-autocomplete="list"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActive(0);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="h-12 w-full bg-transparent text-sm outline-none placeholder:text-[#94A9A7]"
/>
</div>
<ul id={listId} role="listbox" aria-label="Results" className="max-h-80 overflow-y-auto p-2">
{results.map((item, index) => {
const showGroup = item.group && item.group !== results[index - 1]?.group;
return (
<li key={item.id} role="presentation">
{showGroup && (
<p role="presentation" className="px-3 pb-1 pt-3 text-[11px] font-medium uppercase tracking-wider text-[#94A9A7]">
{item.group}
</p>
)}
<div
id={`${listId}-${item.id}`}
role="option"
aria-selected={index === current}
onMouseMove={() => setActive(index)}
onClick={() => choose(item)}
className="relative flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm"
>
{index === current && (
<motion.span
layoutId={highlightId}
className="absolute inset-0 rounded-lg bg-white/[0.07]"
transition={reduceMotion ? { duration: 0 } : { type: "spring", bounce: 0, duration: 0.25 }}
/>
)}
{item.icon && <span className="relative flex size-4 text-[#94A9A7] [&>svg]:size-4">{item.icon}</span>}
<span className="relative flex-1">{item.label}</span>
{item.hint && <span className="relative text-xs text-[#94A9A7]">{item.hint}</span>}
</div>
</li>
);
})}
{results.length === 0 && (
<li role="presentation" className="px-3 py-8 text-center text-sm text-[#94A9A7]">
No results for “{query}”
</li>
)}
</ul>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}
function SearchIcon() {
return (
<svg aria-hidden viewBox="0 0 24 24" className="size-4 shrink-0" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| items | CommandItem[] | — | { id, label, group?, icon?, hint?, keywords?, onSelect? } |
| shortcut | string | — | Letter that opens it with ⌘ or Ctrl, e.g. "k". |
| placeholder | string | "Type a command or search…" | Search box placeholder. |
| triggerLabel | string | "Search" | Text on the trigger button. |
Command Centre
A ⌘K command palette with recent actions, fuzzy search across pages and commands, and scope tabs.
Overview
A sliding pill follows your selection.
Animated Tabs
Accessible tabs with a sliding indicator and soft content transitions.
Magnetic Dock
A floating icon bar that magnifies and lifts around the pointer, with tooltips and a gliding active indicator.