Notion, Linear and Slack all have it: type / and a command menu opens. It looks like one feature. It is two, and the tutorials only ever build the second.
Problem one: does this slash mean a command? Because these do not:
| you typed | should the menu open? |
|---|---|
1/2 |
no — a fraction |
https:// |
no — a URL |
src/app |
no — a path |
12/08/2026 |
no — a date |
and/or |
no |
hello / |
yes |
/ at line start |
yes |
Try it, with the table computed live: https://dev48.infy.uk/design/day64-slash-command-menu.html
The rule turns out to be small: the slash must open a word. Start of the line, or preceded by whitespace. Nothing else.
function slashContext(text, caret){
const i = text.lastIndexOf('/', caret - 1);
if (i === -1) return null;
const before = i === 0 ? '' : text[i - 1];
if (before !== '' && !/\s/.test(before)) return null; // 1/2, https://, src/app
const query = text.slice(i + 1, caret);
if (query.length > 24 || /\s{2}|\n/.test(query)) return null;
return { start: i, query };
}
That last condition is the thing nobody implements: a runaway guard. Somebody types a stray slash mid-paragraph and keeps writing. Without a guard the menu sits there, open, matching nothing, for the rest of the document. More than 24 characters or a newline and it closes.
Problem two: matching is subsequence, not substring
h1 has to reach Heading 1. td has to reach To-do list. Neither is a substring of its target. indexOf is the wrong tool and it is what most implementations reach for.
function fuzzyScore(query, target){
const q = query.toLowerCase(), t = target.toLowerCase();
let qi = 0, score = 0, streak = 0;
const hits = [];
for (let ti = 0; ti < t.length && qi < q.length; ti++){
if (t[ti] !== q[qi]) { streak = 0; continue; }
hits.push(ti);
score += 1 + streak * 2; // consecutive letters are worth more
if (ti === 0) score += 8; // and a prefix hit most of all
else if (/[\s-]/.test(t[ti - 1])) score += 4; // word boundary
streak++; qi++;
}
return qi === q.length ? { score, hits } : null; // all letters, in order
}
The weights are the fun part and the dangerous part. co should put Code block above Callout, and it does — prefix scores 20.6 where the same letters scattered score 2.0.
The check that made the weights safe to tune
Here is the risk with a scorer like that: every time you nudge a weight, you might silently change which commands match, not merely their order. A ranking change is a taste question. A membership change is a bug.
So the scorer is fuzzed against an independently written function that answers only the membership question, with no weights in it at all:
const isSubsequence = (q, t) => {
let i = 0;
for (const ch of t) if (ch === q[i]) i++;
return i === q.length;
};
// 20,000 random query/command pairs
for (const [q, cmd] of pairs){
const scored = fuzzyScore(q, cmd) !== null;
const plain = isSubsequence(q.toLowerCase(), cmd.toLowerCase());
if (scored !== plain) disagreements++;
}
20,000 pairs, 4,310 matches, 0 disagreements. Now the weights are free to change: the second function pins what matches, so only the ordering is up for debate.
That is the general shape and it is worth stealing — when one function does two jobs, write a second function that does only the job you cannot afford to break, and fuzz them against each other.
Two small things that make it feel finished
Highlights are asserted, not eyeballed. fuzzyScore returns the matched positions, and the tests check they are strictly increasing and that reading them off the target spells the query back. Off-by-one highlighting is invisible in review and obvious to a user.
Empty query is not a special case. No query means every command, ranked by nothing. And a genuinely unmatched query returns zero items so the menu can show a real empty state rather than a blank box.
Part of a from-scratch series — one component a day, vanilla JS, one file, offline: https://dev48.infy.uk/designfromzero.php
Top comments (0)