How I'd Think About This Problem
The slash-command popup looks like a UI question and is actually a focus-and-caret question wearing a UI costume. The visual design is trivial — a list of items with a highlight. The hard part is that the popup has to feel like an extension of the textarea: keyboard events stay glued to the input, focus never visibly leaves, and the caret position drives every decision the menu makes. Get any of those wrong and the experience degrades from "magical" to "fights me." This is exactly why Notion, Linear, and Cursor put real engineering hours into a feature that looks like a weekend project.
The mental model I use: the textarea is the source of truth for caret position and content. The menu is a derived projection of three signals — is the trigger active, what is the partial query, what is the active index. State doesn't flow upward from the menu to the textarea; it flows downward from the textarea (via input events) into derived menu state. Once you internalize that direction of flow, the rest of the implementation falls out naturally.
Detecting the Trigger — The Regex Matters
const before = value.slice(0, caret);
const match = before.match(/(?:^|\s)(\/[A-Za-z]*)$/);
if (match) {
triggerPosRef.current = caret - match[1].length;
setQuery(match[1].slice(1).toLowerCase());
setOpen(true);
}
Three anchors matter and each one prevents a real bug:
(?:^|\s) — ensures the slash is at start of input or right after whitespace. Without this, typing a URL like https://example.com opens the menu. With it, it doesn't.
\/[A-Za-z]* — accept letters only after the slash. Without restricting, / (slash-space) re-opens the menu while the user is just typing punctuation.
$ — the end-anchor binds to the caret position, not the end of the full string. This is critical because the user might be editing in the middle of a long message; the trigger only fires for the slash adjacent to the caret.
If you only remember one thing: the substring you regex against is value.slice(0, caret), not the full value. The caret is the only "now" the user cares about.
Splicing Without Eating Text
const insert = (cmd) => {
const caret = inputRef.current.selectionStart;
const start = triggerPosRef.current;
const next = value.slice(0, start) + cmd.name + ' ' + value.slice(caret);
setValue(next);
requestAnimationFrame(() => {
const newCaret = start + cmd.name.length + 1;
inputRef.current.setSelectionRange(newCaret, newCaret);
});
};
Two slices, not three. The range between start (trigger position) and caret (current cursor) is the partial query the user was typing — we throw it away and replace with the full command name plus a trailing space. The trailing space is intentional: it advances the user past the command and exits the trigger zone (since space breaks the regex match).
Why requestAnimationFrame? React's setState is asynchronous. If you call setSelectionRange immediately after setValue, you're setting selection on the OLD textarea content and the position is wrong. rAF defers until after React commits the new value to the DOM. In React 18 with concurrent features, flushSync from react-dom is the more explicit alternative — but rAF is simpler and works fine here.
⚠ Common Pitfall: onClick on menu items
If you wire menu selection to onClick, here's the event order: mousedown → textarea blurs → click fires → your code reads selectionStart from a textarea that's no longer focused. selectionStart on a blurred textarea returns the last-focused position, which is stale by the time mouseUp fires. Result: caret lands in the wrong place after insert. Use onMouseDown with e.preventDefault() instead — preventDefault on mousedown stops the focus transfer entirely. The textarea stays focused, the splice is correct.
⚠ Common Pitfall: Forgetting IME composition
If your users type Japanese, Chinese, or Korean, the input goes through a multi-step composition before the final character is committed. Naive onChange handlers fire on every intermediate keystroke, which fires your trigger detection on partial composition state — and the user sees the menu flicker open and closed. The fix: track onCompositionStart / onCompositionEnd; suppress trigger detection while isComposing.current === true. This is invisible in English-only testing and a P0 bug in Asian markets.
Keyboard Discipline — preventDefault Is Mandatory
While the menu is open, ArrowUp/ArrowDown should NOT move the textarea cursor — they should move the menu's active index. Without e.preventDefault() in your keydown handler, both happen: the cursor moves AND the selection changes. Same for Enter — without preventDefault it inserts a newline (or submits a parent form). Same for Escape — without preventDefault, in some browsers it can cancel an outer modal.
const onKeyDown = (e) => {
if (!open) return; // critical — let the textarea behave normally otherwise
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActive(a => (a + 1) % filtered.length);
break;
case 'ArrowUp':
e.preventDefault();
setActive(a => (a - 1 + filtered.length) % filtered.length);
break;
case 'Enter':
if (filtered[active]) {
e.preventDefault();
insert(filtered[active]);
}
break;
case 'Escape':
e.preventDefault();
setOpen(false);
break;
}
};
The if (!open) return guard at the top is non-negotiable. Without it, every Enter keypress in your textarea (when no menu is open) triggers your handler and you've broken normal text entry. Always check that you're in the right mode before stealing keys.
Going to Production: What This Toy Misses
- Portal the menu — for production, render the popup with
createPortal to document.body. Embedded inside the textarea's parent it gets clipped by ancestors with overflow: hidden, gets its z-index trampled by other layers, and breaks when the textarea is inside a scroll container. A library like Floating UI handles this plus collision detection (flip up if no room below).
- Caret coordinates for positioning — the simplistic
position: absolute; bottom: 100% works only if the menu sits relative to the whole textarea. For real editors (think CodeMirror, Monaco, ProseMirror) the menu should appear at the actual caret coordinate. Build a hidden mirror div with identical font/padding, find the visual position of the caret in the mirror, and place the menu there. This is what Notion does.
- Fuzzy match scoring —
startsWith is fine for a list of 8 commands; useless for 80. Use a Sublime/fzf-style scorer (lowercase prefix > subsequence > substring) or pull in fuse.js. Sort filtered results by score, not source order.
- ARIA combobox pattern — set
role="combobox", aria-expanded, aria-controls, aria-activedescendant on the textarea; role="listbox" on the menu; role="option" + unique IDs on items. Without this, screen-reader users have no idea the menu exists.
- Generalize to multiple triggers — Notion uses
/ for blocks and @ for mentions. Don't write two separate detectors; build one trigger registry: { '/': commands, '@': users }. The detector regex becomes parameterized over which trigger characters to look for.
ℹ Interview Tip — Frame This As Focus Coordination
Open with: "This question is really about coordinating focus between two elements that should feel like one. The textarea owns content and caret; the menu owns selection. I want to make sure focus never visibly leaves the textarea — that's why I'll use mousedown with preventDefault and a regex that anchors to the live caret position." Naming the underlying problem (focus coordination) before writing code shows you've actually shipped something like this. Most candidates dive straight into the regex and never verbalize the focus contract.