I added a ⌘K command palette to my own portfolio recently, the kind of thing that shows up in a lot of developer tools now, and wanted an honest accounting of what’s actually involved. It sounds like “an input with a filtered list under it,” and the list part really is that simple. Almost everything else about a command palette that makes it feel like a real tool instead of a search box lives in the parts nobody mentions: the global shortcut, a command registry instead of a growing conditional, history navigation, and a surprisingly involved amount of focus management.
The global shortcut has to live outside the thing it opens
The obvious first mistake: putting the keydown listener for Cmd/Ctrl+K inside the palette component itself. That component is conditionally rendered, so if it’s closed, its listener doesn’t exist, and you can never open it in the first place. The listener has to live in whatever component owns the open/closed state, bound at the document level, active regardless of whether the palette is currently visible.
useEffect(() => {
function onKeydown(e) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen(true);
} else if (e.key === "Escape") {
setOpen(false);
}
}
document.addEventListener("keydown", onKeydown);
return () => document.removeEventListener("keydown", onKeydown);
}, []);A command registry instead of a growing if/else
The naive version is a chain of if (cmd === "x") checks that gets uglier with every new command. A plain object mapping command names to handler functions makes adding a new command a one-line addition, and it doubles as your own documentation of everything the palette can do.
const commands = {
help: () => print("Available: projects, contact, resume, social, clear"),
projects: () => projects.forEach((p) => print(`${p.name}: ${p.desc}`)),
resume: () => print(`<a href="${RESUME_URL}" download>Download resume</a>`),
clear: () => setLines([]),
};
function runCommand(raw) {
const cmd = raw.trim().toLowerCase();
if (commands[cmd]) commands[cmd]();
else print(`command not found: ${raw}`);
}History navigation is the part that makes it feel real
Pressing the up arrow to cycle back through previous commands, the way an actual shell works, is a small detail that does a lot of work in making the palette feel like a real tool rather than a styled search input. The part worth getting right: history is tracked in a ref and an index, not in React state, because every keystroke while typing shouldn’t trigger a re-render just to keep a history array current that nothing is currently displaying.
const historyRef = useRef([]);
const historyIdxRef = useRef(-1);
function handleKeyDown(e) {
if (e.key === "Enter") {
runCommand(value);
historyRef.current.push(value);
historyIdxRef.current = historyRef.current.length;
setValue("");
} else if (e.key === "ArrowUp" && historyIdxRef.current > 0) {
historyIdxRef.current -= 1;
setValue(historyRef.current[historyIdxRef.current]);
} else if (e.key === "ArrowDown") {
historyIdxRef.current = Math.min(historyIdxRef.current + 1, historyRef.current.length);
setValue(historyRef.current[historyIdxRef.current] ?? "");
}
}Focus that just works, and scroll that follows output
Two small pieces of polish that are easy to skip and immediately noticeable when they’re missing: autofocusing the input the instant the dialog opens, and auto-scrolling the output pane to the bottom whenever new output is printed, the way a real terminal does. The autofocus needs a tiny delay in some browsers, calling .focus() in the same tick the element becomes visible can silently fail, so a setTimeout of a few milliseconds after the open transition starts is more reliable than focusing immediately.
The accessibility layer, which is not optional
A command palette is a dialog, and it needs to behave like one: role="dialog", aria-modal="true", Escape closing it from anywhere inside, and focus returning to whatever triggered it on close rather than resetting to the top of the page. None of this is specific to command palettes, it’s the same pattern any custom dialog needs, but it’s easy to treat a command palette as a “fun feature” and forget it’s still a modal dialog that needs the same rigor as anything else that takes over the screen.
The actual takeaway
None of these pieces are individually hard. What makes a command palette a genuinely good exercise is that it touches keyboard event handling, focus management, and a small but real state architecture decision (ref versus state for history) all inside one small component, more than most isolated UI examples do at once. It’s a good project specifically because it forces you to get several small things right at the same time instead of one thing in isolation.