A command palette (Cmd+K) gives power users instant access to navigation, search, and actions. The cmdk library makes this easy to build.
Step 1: Install cmdk
pnpm add cmdk
Step 2: Basic Command Palette
"use client";
import { useState, useEffect } from "react";
import { Command } from "cmdk";
export function CommandPalette() {
const [open, setOpen] = useState(false);
// Toggle with Cmd+K
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => !prev);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
if (!open) return null;
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/50"
onClick={() => setOpen(false)}
/>
<div className="relative mx-auto mt-[20vh] max-w-lg">
<Command className="overflow-hidden rounded-xl border bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<Command.Input
placeholder="Type a command or search..."
className="w-full border-b px-4 py-3 text-sm outline-none dark:border-gray-700 dark:bg-gray-900"
/>
<Command.List className="max-h-80 overflow-y-auto p-2">
<Command.Empty className="py-6 text-center text-sm text-gray-500">
No results found.
</Command.Empty>
<Command.Group heading="Navigation" className="mb-2">
<CommandItem onSelect={() => navigate("/")}>
Home
</CommandItem>
<CommandItem onSelect={() => navigate("/services")}>
Services
</CommandItem>
<CommandItem onSelect={() => navigate("/blog")}>
Blog
</CommandItem>
<CommandItem onSelect={() => navigate("/pricing")}>
Pricing
</CommandItem>
<CommandItem onSelect={() => navigate("/contact")}>
Contact
</CommandItem>
</Command.Group>
<Command.Group heading="Actions" className="mb-2">
<CommandItem onSelect={() => toggleTheme()}>
Toggle Dark Mode
</CommandItem>
<CommandItem onSelect={() => copyUrl()}>
Copy Page URL
</CommandItem>
</Command.Group>
</Command.List>
</Command>
</div>
</div>
);
}
function CommandItem({
children,
onSelect,
}: {
children: React.ReactNode;
onSelect: () => void;
}) {
return (
<Command.Item
onSelect={onSelect}
className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm text-gray-700 aria-selected:bg-blue-50 aria-selected:text-blue-600 dark:text-gray-300 dark:aria-selected:bg-blue-900/20"
>
{children}
</Command.Item>
);
}
Step 3: Style the Command Groups
/* Add to globals.css */
[cmdk-group-heading] {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: rgb(107 114 128);
text-transform: uppercase;
letter-spacing: 0.05em;
}
Step 4: Add Icons
<Command.Group heading="Navigation">
<CommandItem onSelect={() => navigate("/")}>
<HomeIcon className="h-4 w-4" />
<span>Home</span>
</CommandItem>
<CommandItem onSelect={() => navigate("/services")}>
<BriefcaseIcon className="h-4 w-4" />
<span>Services</span>
</CommandItem>
<CommandItem onSelect={() => navigate("/blog")}>
<BookIcon className="h-4 w-4" />
<span>Blog</span>
</CommandItem>
<CommandItem onSelect={() => navigate("/contact")}>
<MailIcon className="h-4 w-4" />
<span>Contact</span>
</CommandItem>
</Command.Group>
Step 5: Add Keyboard Shortcuts Display
function CommandItem({
children,
shortcut,
onSelect,
}: {
children: React.ReactNode;
shortcut?: string;
onSelect: () => void;
}) {
return (
<Command.Item
onSelect={onSelect}
className="flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm aria-selected:bg-blue-50 dark:aria-selected:bg-blue-900/20"
>
<div className="flex items-center gap-3">{children}</div>
{shortcut && (
<kbd className="rounded border px-1.5 py-0.5 text-xs text-gray-400 dark:border-gray-600">
{shortcut}
</kbd>
)}
</Command.Item>
);
}
// Usage
<CommandItem onSelect={toggleTheme} shortcut="⌘D">
Toggle Dark Mode
</CommandItem>
Step 6: Dynamic Search Results
"use client";
import { useState, useEffect } from "react";
import { Command } from "cmdk";
export function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!query.trim()) {
setSearchResults([]);
return;
}
setLoading(true);
const timeout = setTimeout(async () => {
const results = await searchContent(query);
setSearchResults(results);
setLoading(false);
}, 200);
return () => clearTimeout(timeout);
}, [query]);
return (
<Command shouldFilter={false}>
<Command.Input
value={query}
onValueChange={setQuery}
placeholder="Search..."
/>
<Command.List>
{loading && (
<Command.Loading>
<div className="py-4 text-center text-sm text-gray-500">
Searching...
</div>
</Command.Loading>
)}
{!loading && query && searchResults.length === 0 && (
<Command.Empty>No results found.</Command.Empty>
)}
{searchResults.length > 0 && (
<Command.Group heading="Search Results">
{searchResults.map((result) => (
<Command.Item
key={result.slug}
value={result.title}
onSelect={() => navigate(result.slug)}
>
<span>{result.title}</span>
<span className="text-xs text-gray-400">{result.category}</span>
</Command.Item>
))}
</Command.Group>
)}
{/* Static items always shown */}
{!query && (
<>
<Command.Group heading="Quick Links">
{/* navigation items */}
</Command.Group>
<Command.Group heading="Actions">
{/* action items */}
</Command.Group>
</>
)}
</Command.List>
</Command>
);
}
Step 7: Nested Commands (Sub-menus)
const [pages, setPages] = useState<string[]>([]);
const activePage = pages[pages.length - 1];
<Command
onKeyDown={(e) => {
if (e.key === "Backspace" && !query) {
setPages((prev) => prev.slice(0, -1));
}
}}
>
{!activePage && (
<Command.Group heading="Actions">
<Command.Item onSelect={() => setPages(["theme"])}>
Change Theme...
</Command.Item>
</Command.Group>
)}
{activePage === "theme" && (
<Command.Group heading="Theme">
<Command.Item onSelect={() => setTheme("light")}>Light</Command.Item>
<Command.Item onSelect={() => setTheme("dark")}>Dark</Command.Item>
<Command.Item onSelect={() => setTheme("system")}>System</Command.Item>
</Command.Group>
)}
</Command>
Step 8: Using with Dialog
For better accessibility, wrap in a dialog component:
import * as Dialog from "@radix-ui/react-dialog";
import { Command } from "cmdk";
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed left-1/2 top-[20vh] -translate-x-1/2">
<Command>
{/* command palette content */}
</Command>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
Need Advanced UI Features?
We build web applications with power-user features like command palettes, keyboard shortcuts, and advanced navigation. Contact us for a consultation.