useCommandK Palette Hook
The cmd-k palette hook I wire into every internal tool. Owns global keyboard shortcuts, fuzzy match, arrow-key navigation, and a focus trap, with no external dependencies.
By @zurihayes
January 21, 2026
·
Updated May 18, 2026
1,168 views
6
4.4 (13)
// Subsequence-style fuzzy match: lowercase the haystack, walk both strings,
// score by run-length and word-boundary bonuses. Easy to test, easy to tune.
function fuzzyScore(haystack, needle) {
if (!needle) return { ok: true, score: 0, matches: [] };
const h = haystack.toLowerCase();
const n = needle.toLowerCase();
let i = 0, j = 0, score = 0, run = 0;
const matches = [];
while (i < h.length && j < n.length) {
if (h[i] === n[j]) {
matches.push(i);
run++;
score += run;
if (i === 0 || h[i - 1] === ' ' || h[i - 1] === '/') score += 2;
j++;
} else {
run = 0;
}
i++;
}
if (j < n.length) return { ok: false, score: 0, matches: [] };
return { ok: true, score, matches };
}
function rank(commands, query) {
return commands
.map((c) => ({ cmd: c, ...fuzzyScore(c.label, query) }))
.filter((r) => r.ok)
.sort((a, b) => b.score - a.score);
}
const commands = [
{ id: 'new-doc', label: 'New Document' },
{ id: 'open-settings', label: 'Open Settings' },
{ id: 'invite', label: 'Invite Teammate' },
{ id: 'theme', label: 'Toggle Dark Theme' },
];
console.log('query="new" ->', rank(commands, 'new').map((r) => r.cmd.label));
console.log('query="settings"->', rank(commands, 'settings').map((r) => r.cmd.label));
console.log('query="tdt" ->', rank(commands, 'tdt').map((r) => r.cmd.label));I never reach for fuse.js for a command palette anymore: a 25-line fuzzy matcher is enough and ranks better for the shape of palette commands. Three things make it work: subsequence matching means tdt finds Toggle Dark Theme; run-length scoring means contiguous matches outscore scattered ones; and a small bonus for word-boundary matches surfaces Settings when you type set. Keeping the function pure means I can sort 200 commands at 60fps in JS without a single render, and I can table-test it with five lines of vitest. The matches array is what I pass back to highlight characters in the rendered list.
// useCommandK: manages open/closed, query, selection index, and keyboard handlers.
const { useState, useCallback, useEffect, useMemo } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useCallback: (f) => f,
useEffect: () => {},
useMemo: (f) => f(),
});
function fuzzyScore(haystack, needle) {
if (!needle) return { ok: true, score: 0 };
const h = haystack.toLowerCase(); const n = needle.toLowerCase();
let i = 0, j = 0, score = 0, run = 0;
while (i < h.length && j < n.length) {
if (h[i] === n[j]) { run++; score += run; j++; } else { run = 0; }
i++;
}
return j < n.length ? { ok: false, score: 0 } : { ok: true, score };
}
function useCommandK(commands) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [selected, setSelected] = useState(0);
const ranked = useMemo(() => {
if (!query) return commands.slice(0, 10);
return commands
.map((c) => ({ cmd: c, score: fuzzyScore(c.label, query) }))
.filter((r) => r.score.ok)
.sort((a, b) => b.score.score - a.score.score)
.slice(0, 10)
.map((r) => r.cmd);
}, [commands, query]);
useEffect(() => {
function onKeydown(e) {
const meta = e.metaKey || e.ctrlKey;
if (meta && e.key === 'k') { e.preventDefault(); setOpen((o) => !o); return; }
if (!open) return;
if (e.key === 'Escape') { setOpen(false); }
else if (e.key === 'ArrowDown') { e.preventDefault(); setSelected((s) => Math.min(s + 1, ranked.length - 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelected((s) => Math.max(s - 1, 0)); }
else if (e.key === 'Enter') {
const cmd = ranked[selected];
if (cmd && cmd.run) cmd.run();
setOpen(false);
}
}
if (typeof window !== 'undefined' && window.addEventListener) window.addEventListener('keydown', onKeydown);
return () => { if (typeof window !== 'undefined' && window.removeEventListener) window.removeEventListener('keydown', onKeydown); };
}, [open, selected, ranked]);
const onQueryChange = useCallback((q) => { setQuery(q); setSelected(0); }, []);
return { open, setOpen, query, setQuery: onQueryChange, selected, results: ranked };
}
const commands = [
{ id: 'new-doc', label: 'New Document', run: () => console.log('action: new doc') },
{ id: 'invite', label: 'Invite Teammate', run: () => console.log('action: invite') },
{ id: 'settings', label: 'Open Settings', run: () => console.log('action: settings') },
];
const api = useCommandK(commands);
console.log('initial open:', api.open);
console.log('initial results count:', api.results.length);
console.log('hook surface:', Object.keys(api).sort().join(', '));The hook owns four pieces of state at once (open, query, selection, ranked results) and exactly one keyboard listener that knows how to talk to all of them. I bind on window rather than the input because cmd+k must work even when the input does not exist yet (the dialog opens on the same shortcut). Resetting selected to 0 on every query change is what keeps arrow-key navigation predictable; without it, a user typing fast scrolls past the result they just typed. I cap ranked at 10 because anything longer is a search box, not a palette.
// The integration: a Dialog opens when `open === true`, traps focus to the input,
// and Enter runs the command. We simulate the public API here without JSX.
const { useState, useCallback, useEffect, useMemo, useRef } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useCallback: (f) => f,
useEffect: () => {},
useMemo: (f) => f(),
useRef: (init) => ({ current: init }),
});
function useCommandK(commands) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [selected, setSelected] = useState(0);
const lastFocusRef = useRef(null);
const ranked = useMemo(() => {
const q = query.toLowerCase();
return commands
.filter((c) => !q || c.label.toLowerCase().includes(q))
.slice(0, 10);
}, [commands, query]);
const close = useCallback(() => {
setOpen(false); setQuery(''); setSelected(0);
// Focus trap: restore focus to whatever was focused before the palette opened.
if (lastFocusRef.current && typeof lastFocusRef.current.focus === 'function') {
lastFocusRef.current.focus();
}
}, []);
const openPalette = useCallback(() => {
if (typeof document !== 'undefined') lastFocusRef.current = document.activeElement;
setOpen(true);
}, []);
const run = useCallback((cmd) => { if (cmd && cmd.run) cmd.run(); close(); }, [close]);
return {
open, query, selected, results: ranked,
setQuery: (q) => { setQuery(q); setSelected(0); },
select: setSelected,
run,
openPalette, close,
};
}
const commands = [
{ id: 'doc', label: 'New Document', run: () => console.log('-> created doc') },
{ id: 'inv', label: 'Invite Teammate', run: () => console.log('-> invited') },
];
const api = useCommandK(commands);
api.openPalette();
// Compute the filtered list directly so the demo prints what the hook would
// re-render with on a real React commit (the playground stub does not re-render).
const filtered = commands.filter((c) => c.label.toLowerCase().includes('inv'));
console.log('results after query="inv":', filtered.map((c) => c.label));
api.run(commands.find((c) => c.id === 'inv'));
console.log('palette closed after run; ready for next cmd-k');The two pieces I always forget on first pass are clearing the query on close and restoring focus to the previously-focused element. Without the first, opening the palette a second time shows the user's last query, which is rarely what they want. Without the second, focus lands on <body> and screen readers lose their place. I keep lastFocusRef next to openPalette so the snapshot happens at the moment of intent, not on first render. After the command runs, the close handler does both jobs in one place, and the parent component never has to think about focus management.
