Ranking a command palette
Every tool I build ends up with a command palette eventually, and the internal tool I built at work got one too: Ctrl or ⌘ and K, type a few letters, jump to a project, a person, a page, or run an action. The box is the easy part. The interesting part is the order the results come out in, because a palette that puts the wrong thing first is just a slower menu.
This one ranks with about ten lines of scoring and no fuzzy-matching library. Here it is with forty-odd invented items, and a readout underneath that shows the arithmetic.
Open the palette with the button or the shortcut.
The scoring
Both the query and every item are lower-cased and run through normalize("NFKD"), which splits an accented letter into the letter plus its accent, and then the accents are stripped. So zoe finds Zoë and alvarez finds Álvarez, which matters more than you'd think in a company with a lot of names.
The query is split into words and every word must match somewhere, or the item is out. Each word then scores by where it matched: +6 if the label starts with it, +4 if any word in the label starts with it, +2 if it's anywhere in the label, and +1 if it only turned up in the hint or the hidden keywords. Finally the label's length divided by 200 is taken off, which does nothing except break ties in favour of the shorter label. That's it. Top twelve win.
const norm = (s) => s.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "");
for (const w of words) {
if (label.startsWith(w)) score += 6;
else if (labelWords.some((lw) => lw.startsWith(w))) score += 4;
else if (label.includes(w)) score += 2;
else if (rest.includes(w)) score += 1;
else { ok = false; break; }
}
if (ok) scored.push({ item, score: score - label.length / 200 });
Why this order
Type north and the project North star beats the person Harriet North: six for starting the label against four for starting a word in it. Type set and Settle, Settings and Set status all score six, so they come out shortest first, separated only by length over 200. Type dark and the Switch theme action shows up even though "dark" is nowhere in its name; that's what the keywords are for.
And type soren, and Søren doesn't appear. NFKD only strips things Unicode thinks are accents, and ø isn't one of them. Try ren instead: the label splits on anything that isn't a-z or 0-9, so "søren" becomes "s" and "ren", and he comes back on a word prefix. I've left it alone; it's honest about what it does, and nobody has complained yet.
Small things
The list is an ARIA combobox and listbox. Moving the mouse over a row makes it the active one, so the pointer and the arrow keys never disagree about what Enter will do. The last row is always See all results for "…", so a query that matches nothing is never a dead end, and Ctrl/⌘ + Enter jumps straight to it from anywhere in the list. A footer spells out the keys, because nobody guesses Ctrl+Enter.
Two things here are additions for the demo: the shortcut only listens while the demo is on screen (a page that steals Ctrl K from your browser forever is rude), and I wired up aria-activedescendant and focus return when it closes, which the original could do with.
- Built
- November 2026
- Stack
- Next.js, React, TypeScript
- This page
- Vanilla JS, the ranking ported line for line
- Lives at
- An internal tool at work
- Source
- Private