A ⌘K command palette with recent actions, fuzzy search across pages and commands, and scope tabs.
Customize
Usage
import { useRouter } from "next/navigation";
import { CommandCentre } from "@/components/motionkit/command-centre";
const router = useRouter();
<CommandCentre
onNavigate={router.push}
pages={[
{ label: "Dashboard", href: "/dashboard", description: "Overview and key metrics" },
{ label: "Billing", href: "/settings/billing", keywords: ["invoice"] },
]}
commands={[
{ id: "new", label: "New project", group: "Create", shortcut: "⌘N", onSelect: createProject },
{ id: "deploy", label: "Deploy to production", group: "Actions", keywords: ["ship"], onSelect: deploy },
]}
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Dialog in
Opened
Highlight glide
Arrow keys or hover
Scope switch
Tab or click
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/command-centre.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 CommandAction = {
id: string;
label: string;
/** Heading the command is listed under. */
group?: string;
icon?: ReactNode;
/** Shown on the right, e.g. "⌘N". Display only: wire the real shortcut yourself. */
shortcut?: string;
/** Extra words that should match a search. */
keywords?: string[];
onSelect: () => void;
};
export type CommandPage = {
label: string;
href: string;
/** One line under the label. Also searchable. */
description?: string;
icon?: ReactNode;
keywords?: string[];
};
type CommandCentreProps = {
commands?: CommandAction[];
pages?: CommandPage[];
/** Called with a page's href. Pass your router's push; defaults to a full page load. */
onNavigate?: (href: string) => void;
/** Letter that opens it with ⌘ (Mac) or Ctrl. `false` for no shortcut. */
hotkey?: string | false;
/** localStorage key for recent actions. `null` turns recents off. */
storageKey?: string | null;
/** How many recent actions to remember. */
recentLimit?: number;
placeholder?: string;
/** Text on the trigger button. */
triggerLabel?: string;
className?: string;
};
type Scope = "all" | "pages" | "commands";
type Entry = {
key: string;
kind: "page" | "command";
label: string;
group: string;
icon?: ReactNode;
hint?: string;
description?: string;
keywords: string[];
run: () => void;
};
type Result = { entry: Entry; score: number; matches: number[] };
const scopes: { id: Scope; label: string }[] = [
{ id: "all", label: "All" },
{ id: "pages", label: "Pages" },
{ id: "commands", label: "Commands" },
];
// ---- search ---------------------------------------------------------------
/** Ranks a label against the query: exact prefix, then word start, then substring, then letters in order. */
function matchLabel(label: string, query: string): { score: number; matches: number[] } | null {
const text = label.toLowerCase();
const at = text.indexOf(query);
if (at !== -1) {
const score = at === 0 ? 100 : text[at - 1] === " " ? 80 : 60;
return { score, matches: Array.from({ length: query.length }, (_, i) => at + i) };
}
// fuzzy: every query letter appears in order; tighter spans score higher
const matches: number[] = [];
let from = 0;
for (const char of query) {
if (char === " ") continue;
const index = text.indexOf(char, from);
if (index === -1) return null;
matches.push(index);
from = index + 1;
}
const span = matches[matches.length - 1] - matches[0] + 1;
return { score: 40 - Math.min(span - matches.length, 30), matches };
}
function rank(entry: Entry, query: string): Result | null {
const label = matchLabel(entry.label, query);
if (label) return { entry, ...label };
// keywords, descriptions and groups count, but below any label match and without highlighting
const extra = [entry.description, entry.group, ...entry.keywords].join(" ").toLowerCase();
return extra.includes(query) ? { entry, score: 5, matches: [] } : null;
}
// ---- recents --------------------------------------------------------------
function readRecent(storageKey: string | null): string[] {
if (!storageKey) return [];
try {
const saved = JSON.parse(window.localStorage.getItem(storageKey) ?? "[]");
return Array.isArray(saved) ? saved.filter((key) => typeof key === "string") : [];
} catch {
return [];
}
}
function writeRecent(storageKey: string | null, keys: string[]) {
if (!storageKey) return;
try {
window.localStorage.setItem(storageKey, JSON.stringify(keys));
} catch {
// storage unavailable: recents still work until the page closes
}
}
// ---- platform -------------------------------------------------------------
// true only in the browser, after hydration, so the portal never mismatches server HTML
const noop = () => () => {};
const useIsClient = () => useSyncExternalStore(noop, () => true, () => false);
// the server renders ⌘; other platforms switch to Ctrl after hydration
const useIsMac = () =>
useSyncExternalStore(noop, () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent), () => true);
export function CommandCentre({
commands = [],
pages = [],
onNavigate,
hotkey = "k",
storageKey = "motionkit-command-centre",
recentLimit = 5,
placeholder = "Search pages and commands…",
triggerLabel = "Search",
className = "",
}: CommandCentreProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [scope, setScope] = useState<Scope>("all");
const [active, setActive] = useState(0);
const [recent, setRecent] = useState<string[]>([]);
const trigger = useRef<HTMLButtonElement>(null);
const reduceMotion = useReducedMotion();
const listId = useId();
const highlightId = useId();
const isClient = useIsClient();
const isMac = useIsMac();
const entries: Entry[] = [
...commands.map((command) => ({
key: `command:${command.id}`,
kind: "command" as const,
label: command.label,
group: command.group ?? "Commands",
icon: command.icon ?? <BoltIcon />,
hint: command.shortcut,
keywords: command.keywords ?? [],
run: command.onSelect,
})),
...pages.map((page) => ({
key: `page:${page.href}`,
kind: "page" as const,
label: page.label,
group: "Pages",
icon: page.icon ?? <PageIcon />,
description: page.description,
keywords: [page.href, ...(page.keywords ?? [])],
run: () => (onNavigate ? onNavigate(page.href) : window.location.assign(page.href)),
})),
];
const inScope = entries.filter((entry) => scope === "all" || entry.kind === (scope === "pages" ? "page" : "command"));
const search = query.trim().toLowerCase();
// with no query: recents first, then everything in its group; with a query: best matches first
let results: (Result & { heading?: string })[];
if (search) {
results = inScope
.map((entry) => rank(entry, search))
.filter((result): result is Result => result !== null)
.sort((a, b) => b.score - a.score)
.map((result, index) => ({ ...result, heading: index === 0 ? "Best matches" : undefined }));
} else {
const recentEntries = recent
.map((key) => inScope.find((entry) => entry.key === key))
.filter((entry): entry is Entry => Boolean(entry));
const rest = inScope.filter((entry) => !recentEntries.includes(entry));
results = [
...recentEntries.map((entry, index) => ({ entry, score: 0, matches: [], heading: index === 0 ? "Recent" : undefined })),
...rest.map((entry, index) => ({
entry,
score: 0,
matches: [],
heading: entry.group !== rest[index - 1]?.group ? entry.group : undefined,
})),
];
}
const current = Math.min(active, Math.max(results.length - 1, 0));
const optionId = (key: string) => `${listId}-${key.replace(/[^\w-]/g, "_")}`;
const activeKey = results[current]?.entry.key;
function show() {
setRecent(readRecent(storageKey));
setOpen(true);
}
useEffect(() => {
if (!hotkey) 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() === hotkey.toLowerCase()) {
event.preventDefault();
setRecent(readRecent(storageKey));
setOpen((value) => !value);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [hotkey, storageKey]);
// keep the highlighted option visible as the arrow keys move past the edge of the list
useEffect(() => {
if (open && activeKey) document.getElementById(optionId(activeKey))?.scrollIntoView({ block: "nearest" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, activeKey]);
function close() {
setOpen(false);
setQuery("");
setScope("all");
setActive(0);
trigger.current?.focus();
}
function choose(entry: Entry | undefined) {
if (!entry) return;
const next = [entry.key, ...recent.filter((key) => key !== entry.key)].slice(0, recentLimit);
setRecent(next);
writeRecent(storageKey, next);
close();
entry.run();
}
function move(by: number) {
if (results.length) setActive((current + by + results.length) % results.length);
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
const ctrl = event.ctrlKey && !event.metaKey;
if (event.key === "ArrowDown" || (ctrl && event.key === "n")) move(1);
else if (event.key === "ArrowUp" || (ctrl && event.key === "p")) move(-1);
else if (event.key === "Enter") choose(results[current]?.entry);
else if (event.key === "Escape") {
// first Escape clears the search, the second closes
if (query) {
setQuery("");
setActive(0);
} else close();
} else if (event.key === "Tab") {
// the input is the only focusable thing inside, so Tab switches scope instead
const index = scopes.findIndex((item) => item.id === scope);
setScope(scopes[(index + (event.shiftKey ? -1 : 1) + scopes.length) % scopes.length].id);
setActive(0);
} else return;
event.preventDefault();
}
const modifier = isMac ? "⌘" : "Ctrl ";
return (
<>
<button
ref={trigger}
type="button"
aria-haspopup="dialog"
aria-keyshortcuts={hotkey ? `${isMac ? "Meta" : "Control"}+${hotkey.toUpperCase()}` : undefined}
onClick={show}
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}
{hotkey && (
<kbd className="rounded-md border border-white/10 px-1.5 py-0.5 font-sans text-[11px] text-[#94A9A7]">
{modifier}
{hotkey.toUpperCase()}
</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-[12vh] 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 centre"
className="w-full max-w-xl 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.2, duration: 0.35 }}
>
<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={activeKey ? optionId(activeKey) : undefined}
aria-autocomplete="list"
aria-describedby={`${listId}-help`}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActive(0);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="h-14 w-full bg-transparent text-[15px] outline-none placeholder:text-[#94A9A7]"
/>
</div>
<div role="tablist" aria-label="Search in" className="flex gap-1 border-b border-white/10 px-3 py-2">
{scopes.map((item) => (
<button
key={item.id}
type="button"
role="tab"
tabIndex={-1}
aria-selected={scope === item.id}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setScope(item.id);
setActive(0);
}}
className={`relative rounded-md px-2.5 py-1 text-xs transition-colors ${
scope === item.id ? "text-[#F6F1E8]" : "text-[#94A9A7] hover:text-[#F6F1E8]"
}`}
>
{scope === item.id && (
<motion.span
layoutId={`${highlightId}-scope`}
className="absolute inset-0 rounded-md bg-white/[0.08]"
transition={reduceMotion ? { duration: 0 } : { type: "spring", bounce: 0.2, duration: 0.3 }}
/>
)}
<span className="relative">{item.label}</span>
</button>
))}
</div>
<ul id={listId} role="listbox" aria-label="Results" className="max-h-80 overflow-y-auto p-2">
{results.map(({ entry, matches, heading }, index) => (
<li key={entry.key} role="presentation">
{heading && (
<p role="presentation" className="px-3 pb-1 pt-3 text-[11px] font-medium uppercase tracking-wider text-[#94A9A7]">
{heading}
</p>
)}
<div
id={optionId(entry.key)}
role="option"
aria-selected={index === current}
onMouseMove={() => index !== current && setActive(index)}
onClick={() => choose(entry)}
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 }}
/>
)}
<span className="relative flex size-4 shrink-0 text-[#94A9A7] [&>svg]:size-4">{entry.icon}</span>
<span className="relative min-w-0 flex-1">
<span className="block truncate">
<Highlight text={entry.label} matches={matches} />
</span>
{entry.description && <span className="block truncate text-xs text-[#94A9A7]">{entry.description}</span>}
</span>
{search && <span className="relative text-[11px] text-[#94A9A7]">{entry.kind === "page" ? "Page" : entry.group}</span>}
{entry.hint && (
<kbd className="relative rounded border border-white/10 px-1.5 py-0.5 font-sans text-[11px] text-[#94A9A7]">
{entry.hint}
</kbd>
)}
</div>
</li>
))}
{results.length === 0 && (
<li role="presentation" className="px-3 py-10 text-center text-sm text-[#94A9A7]">
No {scope === "all" ? "results" : scope} for “{query}”
</li>
)}
</ul>
<div
id={`${listId}-help`}
className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-white/10 px-4 py-2.5 text-[11px] text-[#94A9A7]"
>
<span><Key>↑</Key><Key>↓</Key> to move</span>
<span><Key>↵</Key> to open</span>
<span><Key>Tab</Key> to switch scope</span>
<span><Key>Esc</Key> to {query ? "clear" : "close"}</span>
</div>
<p aria-live="polite" className="sr-only">
{results.length} {results.length === 1 ? "result" : "results"}
</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}
/** The label with the matched letters picked out. */
function Highlight({ text, matches }: { text: string; matches: number[] }) {
if (!matches.length) return <>{text}</>;
const set = new Set(matches);
return (
<>
{Array.from(text).map((char, index) =>
set.has(index) ? (
<mark key={index} className="bg-transparent font-medium text-[#5FAEB4]">
{char}
</mark>
) : (
char
),
)}
</>
);
}
function Key({ children }: { children: ReactNode }) {
return <kbd className="mr-1 rounded border border-white/10 px-1 py-px font-sans">{children}</kbd>;
}
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>
);
}
function PageIcon() {
return (
<svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
<path d="M14 3v5h5" />
</svg>
);
}
function BoltIcon() {
return (
<svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M13 2 4 14h7l-1 8 9-12h-7z" />
</svg>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| commands | CommandAction[] | — | { id, label, onSelect, group?, icon?, shortcut?, keywords? } |
| pages | CommandPage[] | — | { label, href, description?, icon?, keywords? } |
| onNavigate | (href: string) => void | window.location.assign | Opens a page. Pass your router's push for client-side navigation. |
| hotkey | string | false | "k" | Letter that opens it with ⌘ or Ctrl. false for none. |
| storageKey | string | null | "motionkit-command-centre" | localStorage key for recent actions. null turns recents off. |
| recentLimit | number | 5 | How many recent actions to remember. |
| placeholder | string | "Search pages and commands…" | Search box placeholder. |
| triggerLabel | string | "Search" | Text on the trigger button. |
Command Menu
A ⌘K palette with live filtering, grouped results and a highlight that glides between them.
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.