A keyboard-driven command palette with fuzzy search, arrow key navigation, and shortcut badges. Inspired by Linear's Cmd+K interface.
Click the button or press ⌘K to open
// Dependencies: react ^18, lucide-react
import React, { useState, useEffect, useRef } from 'react';
import {
Search,
Plus,
FileText,
Settings,
Users,
Bell,
HelpCircle,
ArrowRight,
} from 'lucide-react';
function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(' ');
}
type Command = {
id: string;
icon: React.ReactNode;
label: string;
shortcut?: string[];
group: string;
onSelect: () => void;
};
const COMMANDS: Command[] = [
{
id: 'new-issue',
icon: <Plus className="w-4 h-4" />,
label: 'Create new issue',
shortcut: ['C'],
group: 'Create',
onSelect: () => console.log('Executed: Create new issue'),
},
{
id: 'my-issues',
icon: <FileText className="w-4 h-4" />,
label: 'My issues',
shortcut: ['I'],
group: 'Navigate',
onSelect: () => console.log('Executed: My issues'),
},
{
id: 'team',
icon: <Users className="w-4 h-4" />,
label: 'Team members',
group: 'Navigate',
onSelect: () => console.log('Executed: Team members'),
},
{
id: 'notifications',
icon: <Bell className="w-4 h-4" />,
label: 'Notifications',
shortcut: ['N'],
group: 'Navigate',
onSelect: () => console.log('Executed: Notifications'),
},
{
id: 'settings',
icon: <Settings className="w-4 h-4" />,
label: 'Settings',
shortcut: ['G', 'S'],
group: 'Navigate',
onSelect: () => console.log('Executed: Settings'),
},
{
id: 'help',
icon: <HelpCircle className="w-4 h-4" />,
label: 'Help & documentation',
shortcut: ['?'],
group: 'Help',
onSelect: () => console.log('Executed: Help & documentation'),
},
];
type Props = {
isOpen: boolean;
onClose: () => void;
};
export function CommandPalette({ isOpen, onClose }: Props) {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = COMMANDS.filter((cmd) =>
cmd.label.toLowerCase().includes(query.toLowerCase())
);
useEffect(() => {
setSelectedIndex(0);
}, [query]);
const filteredRef = useRef(filtered);
filteredRef.current = filtered;
const selectedIndexRef = useRef(selectedIndex);
selectedIndexRef.current = selectedIndex;
useEffect(() => {
if (isOpen) {
setQuery('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [isOpen]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (!isOpen) return;
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filteredRef.current.length - 1));
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
}
if (e.key === 'Enter' && filteredRef.current.length > 0) {
filteredRef.current[selectedIndexRef.current].onSelect();
onClose();
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh] bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-[560px] mx-4 bg-zinc-900 border border-white/10 rounded-xl shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Search input */}
<div className="flex items-center gap-3 px-4 border-b border-white/[0.08]">
<Search className="w-4 h-4 text-zinc-500 shrink-0" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search commands..."
className="flex-1 py-4 bg-transparent text-sm text-white placeholder-zinc-500 outline-none"
/>
<kbd className="text-[10px] text-zinc-600 bg-zinc-800 border border-zinc-700 rounded px-1.5 py-0.5">
ESC
</kbd>
</div>
{/* Results list */}
<div className="py-2 max-h-[320px] overflow-y-auto">
{filtered.length === 0 ? (
<p className="px-4 py-8 text-sm text-zinc-500 text-center">
No commands found.
</p>
) : (
filtered.map((cmd, i) => (
<button
key={cmd.id}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 mx-1 rounded-lg text-left transition-colors',
i === selectedIndex
? 'bg-white/[0.08] text-white'
: 'text-zinc-400 hover:bg-white/[0.04] hover:text-zinc-200'
)}
style={{ width: 'calc(100% - 8px)' }}
onMouseEnter={() => setSelectedIndex(i)}
onClick={() => { cmd.onSelect(); onClose(); }}
>
<span className="w-7 h-7 flex items-center justify-center rounded-md bg-zinc-800 text-zinc-400 shrink-0">
{cmd.icon}
</span>
<span className="flex-1 text-sm font-medium">{cmd.label}</span>
{cmd.shortcut && (
<span className="flex items-center gap-1 shrink-0">
{cmd.shortcut.map((key) => (
<kbd
key={key}
className="text-[10px] text-zinc-500 bg-zinc-800 border border-zinc-700 rounded px-1.5 py-0.5"
>
{key}
</kbd>
))}
</span>
)}
{i === selectedIndex && (
<ArrowRight className="w-3.5 h-3.5 text-zinc-500 shrink-0 ml-1" />
)}
</button>
))
)}
</div>
{/* Keyboard hint footer */}
<div className="flex items-center gap-4 px-4 py-2.5 border-t border-white/[0.08] bg-black/20">
<span className="flex items-center gap-1.5 text-[11px] text-zinc-600">
<kbd className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5">
↑↓
</kbd>
navigate
</span>
<span className="flex items-center gap-1.5 text-[11px] text-zinc-600">
<kbd className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5">
↵
</kbd>
select
</span>
<span className="flex items-center gap-1.5 text-[11px] text-zinc-600">
<kbd className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5">
esc
</kbd>
close
</span>
</div>
</div>
</div>
);
}