Building a command palette from scratch means solving fuzzy search, keyboard navigation, and focus management all at once, which is more surface area than most teams want to own for a first version. Fuse.js handles the fuzzy matching piece well, which frees you up to focus on the interaction layer around it. This walkthrough builds a minimal but production-usable command palette in React using Fuse.js for search.
Step 1: Install Fuse.js and Define Your Commands
npm install fuse.js
Commands need a consistent shape so the search and rendering logic can treat them uniformly:
const commands = [
{ id: "new-doc", label: "Create New Document", action: () => createDoc() },
{ id: "invite", label: "Invite Teammate", action: () => openInvite() },
{ id: "settings", label: "Open Settings", action: () => navigate("/settings") },
{ id: "search-docs", label: "Search Documents", action: () => openSearch() },
];
Keep this list as plain data, separate from the component that renders it. That separation makes it trivial to add commands from different parts of the app without touching the palette component itself.
Step 2: Configure Fuse.js Search Options
Fuse.js exposes tunable options that materially affect result quality. The defaults are reasonable but worth understanding rather than accepting blindly.
import Fuse from "fuse.js";
const fuse = new Fuse(commands, {
keys: ["label"],
threshold: 0.3,
ignoreLocation: true,
minMatchCharLength: 2,
});
threshold controls how loose the matching is: 0 requires a perfect match, 1 matches almost anything. A value around 0.3 to 0.4 is a reasonable starting point for command labels, tight enough to avoid noise but forgiving of typos. ignoreLocation: true matters specifically for command palettes, since it removes Fuse's default assumption that matches near the start of the string matter more, which doesn't always hold for command names where the meaningful word can be anywhere ("Open Settings" versus "Settings Panel"). The Fuse.js documentation covers the full options reference, including weighted keys if some commands should rank higher than others regardless of match quality.
Step 3: Build the Palette Component
function CommandPalette({ isOpen, onClose }) {
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef(null);
const results = query
? fuse.search(query).map((r) => r.item)
: commands.slice(0, 5);
useEffect(() => {
if (isOpen) inputRef.current?.focus();
setSelectedIndex(0);
}, [isOpen, query]);
function handleKeyDown(event) {
if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, results.length - 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (event.key === "Enter" && results[selectedIndex]) {
results[selectedIndex].action();
onClose();
} else if (event.key === "Escape") {
onClose();
}
}
if (!isOpen) return null;
return (
<div className="palette-overlay" onClick={onClose}>
<div className="palette" onClick={(e) => e.stopPropagation()}>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a command..."
/>
<ul role="listbox">
{results.map((cmd, i) => (
<li
key={cmd.id}
role="option"
aria-selected={i === selectedIndex}
className={i === selectedIndex ? "selected" : ""}
onClick={() => {
cmd.action();
onClose();
}}
>
{cmd.label}
</li>
))}
</ul>
</div>
</div>
);
}
This covers the core loop: type, filter through Fuse, navigate with arrow keys, execute with Enter or a click. The role="listbox" and role="option" attributes give assistive technology the semantic structure it needs to announce the list correctly, following the ARIA combobox and listbox pattern documented by the W3C.
Step 4: Wire Up the Global Shortcut
function useCommandPaletteShortcut(setIsOpen) {
useEffect(() => {
function handleKeyDown(event) {
const isEditable =
document.activeElement?.tagName === "INPUT" ||
document.activeElement?.tagName === "TEXTAREA";
if ((event.metaKey || event.ctrlKey) && event.key === "k" && !isEditable) {
event.preventDefault();
setIsOpen((open) => !open);
}
if (event.key === "Escape") {
setIsOpen(false);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [setIsOpen]);
}
Checking isEditable before toggling the palette prevents Cmd+K from firing while a user is typing inside another input on the page, which is an easy detail to miss and a common source of "the shortcut fires at the wrong time" complaints once the app has more than one text field on screen.

Photo by Tima Miroshnichenko on Pexels
Step 5: Restore Focus When the Palette Closes
The component above closes the palette but doesn't return focus anywhere specific, which is the single most common gap in homegrown palette implementations. Track what had focus before the palette opened and restore it on close:
function CommandPaletteContainer() {
const [isOpen, setIsOpen] = useState(false);
const previouslyFocused = useRef(null);
useCommandPaletteShortcut((updater) => {
setIsOpen((prev) => {
const next = typeof updater === "function" ? updater(prev) : updater;
if (next && !prev) {
previouslyFocused.current = document.activeElement;
}
if (!next && prev) {
previouslyFocused.current?.focus();
}
return next;
});
});
return <CommandPalette isOpen={isOpen} onClose={() => setIsOpen(false)} />;
}
Skipping this step is invisible in a quick manual test and genuinely disruptive for anyone navigating primarily by keyboard, since focus silently falls back to the document body every time the palette closes instead of returning to where the user actually was.
Step 6: Handle the Empty State
An empty results array with no message reads as a bug, not "no matches." Add a minimal fallback:
{results.length === 0 && query && (
<li className="empty-state">No commands match "{query}"</li>
)}
Small, but it's the difference between a palette that feels finished and one that feels like it's still in development the first time someone mistypes a query.
Step 7: Show Recent Commands Before the User Types Anything
A meaningful share of palette opens are for a small set of repeated actions. Showing the empty-query state as a static slice of the command list, like commands.slice(0, 5) above, is a reasonable starting point, but tracking actual usage produces a noticeably better default view.
function useRecentCommands(maxRecent = 5) {
const [recentIds, setRecentIds] = useState(() => {
try {
return JSON.parse(localStorage.getItem("recentCommands") || "[]");
} catch {
return [];
}
});
function recordUsage(id) {
setRecentIds((prev) => {
const next = [id, ...prev.filter((existing) => existing !== id)].slice(
0,
maxRecent
);
localStorage.setItem("recentCommands", JSON.stringify(next));
return next;
});
}
return { recentIds, recordUsage };
}
Call recordUsage(cmd.id) inside the action handler whenever a command executes, then use recentIds to order the empty-query view instead of a fixed slice. This small addition tends to have an outsized effect on perceived speed, since the commands a specific user reaches for most often are exactly the ones surfaced before they type a single character.
Step 8: Group Results by Category as the Command List Grows
Once the command list passes roughly twenty entries, an undifferentiated list gets harder to scan even with accurate fuzzy matching. Add a category field to each command and group the rendered results:
function groupByCategory(items) {
return items.reduce((groups, item) => {
const key = item.category || "Other";
groups[key] = groups[key] || [];
groups[key].push(item);
return groups;
}, {});
}
Rendering becomes a nested map over the grouped object instead of a flat list, with a small category heading above each group. This is a rendering change only, the underlying Fuse.js search and keyboard navigation logic from Steps 2 through 4 don't need to change, since grouping is purely a presentation concern layered on top of the already-ranked results.
Step 9: Keep Rendering Cheap as the List Grows
For a command list in the low hundreds, the React implementation above renders fine without any special optimization. If your palette needs to search across dynamic content, like documents or records rather than just static commands, cap the rendered results (the top 8 to 10 matches, for instance) rather than rendering every match Fuse.js returns. fuse.search(query, { limit: 10 }) does this directly, avoiding unnecessary DOM work on a list the user is never going to scroll through anyway.
If the palette needs to search across genuinely large datasets, hundreds of thousands of records rather than a few hundred commands, client-side Fuse.js stops being the right tool entirely, and that search needs to move server-side with its own debouncing and loading state, separate from the instant, purely client-side command matching described above.
Testing the Implementation
Beyond manual clicking, verify three things with an automated test: that Cmd+K opens the palette and focuses the input, that Enter on a filtered result executes that command's action, and that closing the palette (by Escape or selection) restores focus to wherever it was before. Testing utilities like Testing Library make asserting on the rendered listbox and the currently focused element straightforward without reaching into component internals.
For a broader treatment of the design decisions behind a command palette beyond this specific implementation, including how to structure command grouping and handle accessibility for screen reader users, the command palette guide from 137Foundry covers the interaction model in more depth in a longer writeup of the full interface.
This gets you a working, keyboard-accessible command palette in well under two hundred lines, with Fuse.js handling the part of the problem (fuzzy ranking) that's easiest to get subtly wrong if you write it from scratch under a deadline.
Top comments (0)