Command Palette
Fast, composable, unstyled command menu. Zero dependencies.
Press Cmd/Ctrl + K or click the button.
Installation
1. Add the CSS to your global stylesheet:
css
/* Command Palette */
.cmd-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); z-index: 100; display: flex; align-items: flex-start; justify-content: center; padding-top: 10vh; }
.cmd-dialog { width: 100%; max-width: 600px; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 12px; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25); overflow: hidden; display: flex; flex-direction: column; }
.cmd-header { display: flex; align-items: center; padding: 16px; border-bottom: 1px solid var(--border-color); }
.cmd-search-icon { color: var(--text-muted); margin-right: 12px; }
.cmd-input { flex: 1; border: none; outline: none; background: transparent; color: var(--text-primary); font-size: 16px; width: 100%; }
.cmd-input::placeholder { color: var(--text-muted); }
.cmd-esc { background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-muted); font-size: 12px; padding: 4px 8px; border-radius: 6px; cursor: pointer; }
.cmd-list { max-height: 400px; overflow-y: auto; padding: 8px; }
.cmd-empty { padding: 16px; text-align: center; color: var(--text-muted); }
.cmd-group { margin-bottom: 8px; }
.cmd-heading { padding: 8px 12px; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.cmd-item { display: flex; align-items: center; padding: 12px; cursor: pointer; border-radius: 8px; transition: background 0.2s, color 0.2s; color: var(--text-primary); }
.cmd-item.selected { background: var(--bg-secondary); color: var(--accent); }
.cmd-icon { margin-right: 12px; display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; color: var(--text-muted); }
.cmd-item.selected .cmd-icon { color: var(--accent); }
.cmd-label { font-size: 14px; font-weight: 500; }2. Copy the React component into your project:
tsx
"use client";
import * as React from "react";
export interface CommandPaletteOption {
id: string;
label: string;
icon?: React.ReactNode;
onSelect: () => void;
}
export interface CommandPaletteGroup {
heading?: string;
items: CommandPaletteOption[];
}
export interface CommandPaletteProps {
isOpen: boolean;
onClose: () => void;
groups: CommandPaletteGroup[];
placeholder?: string;
}
export function CommandPalette({
isOpen,
onClose,
groups,
placeholder = "Type a command or search...",
}: CommandPaletteProps) {
const [search, setSearch] = React.useState("");
const [selectedIndex, setSelectedIndex] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null);
// Filter items
const filteredGroups = React.useMemo(() => {
if (!search) return groups;
const lowerSearch = search.toLowerCase();
return groups
.map((group) => ({
...group,
items: group.items.filter((item) =>
item.label.toLowerCase().includes(lowerSearch),
),
}))
.filter((group) => group.items.length > 0);
}, [groups, search]);
const allFilteredItems = React.useMemo(() => {
return filteredGroups.flatMap((group) => group.items);
}, [filteredGroups]);
React.useEffect(() => {
setSelectedIndex(0);
}, [search, isOpen]);
React.useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
// Use timeout to ensure input is focused after mount and transition
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 50);
return () => {
document.body.style.overflow = "";
clearTimeout(timer);
};
}
}, [isOpen]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex(
(prev) => (prev + 1) % Math.max(allFilteredItems.length, 1),
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex(
(prev) =>
(prev - 1 + allFilteredItems.length) %
Math.max(allFilteredItems.length, 1),
);
} else if (e.key === "Enter") {
e.preventDefault();
if (allFilteredItems[selectedIndex]) {
allFilteredItems[selectedIndex].onSelect();
onClose();
setSearch("");
}
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
setSearch("");
}
};
if (!isOpen) return null;
return (
<div
className="cmd-overlay"
onClick={() => {
onClose();
setSearch("");
}}
>
<div
className="cmd-dialog"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div className="cmd-header">
<svg
className="cmd-search-icon"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
ref={inputRef}
className="cmd-input"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
/>
<button
className="cmd-esc"
onClick={() => {
onClose();
setSearch("");
}}
>
ESC
</button>
</div>
<div className="cmd-list">
{allFilteredItems.length === 0 && (
<div className="cmd-empty">No results found.</div>
)}
{filteredGroups.map((group, groupIndex) => {
// Calculate starting index for this group's items to match global selectedIndex
const prevItemsCount = filteredGroups
.slice(0, groupIndex)
.reduce((acc, g) => acc + g.items.length, 0);
return (
<div key={groupIndex} className="cmd-group">
{group.heading && (
<div className="cmd-heading">{group.heading}</div>
)}
{group.items.map((item, itemIndex) => {
const globalIndex = prevItemsCount + itemIndex;
const isSelected = globalIndex === selectedIndex;
return (
<div
key={item.id}
className={`cmd-item ${isSelected ? "selected" : ""}`}
onMouseEnter={() => setSelectedIndex(globalIndex)}
onClick={() => {
item.onSelect();
onClose();
setSearch("");
}}
>
{item.icon && (
<span className="cmd-icon">{item.icon}</span>
)}
<span className="cmd-label">{item.label}</span>
</div>
);
})}
</div>
);
})}
</div>
</div>
</div>
);
}
Usage
tsx
import { CommandPalette } from '@/components/ui/command-palette';
export default function MyComponent() {
const groups = [
{
heading: 'Navigation',
items: [
{ id: 'home', label: 'Home', onSelect: () => console.log('Home') },
{ id: 'settings', label: 'Settings', onSelect: () => console.log('Settings') }
]
}
];
return (
<>
<button onClick={() => {
// Trigger command palette via keyboard shortcut or state
}}>Open Menu</button>
<CommandPalette
isOpen={true}
onClose={() => {}}
groups={groups}
/>
</>
);
}