Back

Slash-Command Prompt Input

medium

Streak

0 days

Progress

0%

Submitted

0

Slash-Command Prompt Input

React35 minmediumFreeNewAI

Prompt

Slash commands are the modern AI input pattern. Cursor uses them for code actions, Linear for shortcuts, Notion AI for prompt templates. The pattern is identical: type /, a menu opens, fuzzy-search filters as you keep typing, arrow keys navigate, Enter selects, Esc dismisses. It looks trivial. It is not.

Build a textarea that detects when the user types / at the start of input or after whitespace, opens a popup menu of commands, filters as they type, supports full keyboard navigation, and inserts the selected command at the trigger position. Selecting via mouse should not break textarea focus. Selecting via Enter should not submit a form.

The deep insight is that the menu is a slave to the textarea's caret position, not a separate input. The trigger position is captured at the moment / is detected and used later to splice the inserted command back into the source string. Get the splice wrong and you'll either duplicate text or eat the user's content.

Requirements

  • →Typing '/' at start or after whitespace opens the command menu
  • →Menu filters by fuzzy match as the user keeps typing
  • →Arrow Up/Down cycles through filtered commands
  • →Enter inserts the active command at the trigger position
  • →Escape closes the menu without inserting
  • →Clicking an item inserts it without losing textarea focus
  • →Show 'No commands match' state when filter has no hits
Example
Loading preview...
For the best coding experience, we recommend using a desktop device.
Preparing Sandbox...
Premium interview report

What interviewers score in this build

Use this before reading the code. It tells you what to say, what to test, and where machine-coding candidates usually lose points.

Interview signals

  • Trigger detection: Regex correctly identifies '/' at start or after whitespace
  • Caret-aware splice: Insert places command at trigger pos, drops partial query
  • Keyboard nav: ↑↓ cycles, Enter selects, Esc closes; preventDefault used
  • Mouse selection: Click inserts without losing textarea focus

Time checkpoints

  1. 1

    0–3 min: Clarify trigger rules, command shape, keyboard shortcuts

  2. 2

    3–8 min: Scaffold textarea + command list data + popup styling

  3. 3

    8–13 min: Detect trigger via regex, capture trigger position in ref

  4. 4

    13–18 min: Filter list by query, render menu, highlight active item

Edge-case checklist

Empty data and first-load state
Slow network, failed request, and retry path
Keyboard navigation and focus movement
Large input size, re-render pressure, and cleanup

Common mistakes

  • Starting with JSX before naming state and events.
  • Ignoring accessibility until the final minute.
  • Over-building abstractions instead of finishing the required behavior.
  • Failing to narrate trade-offs while coding.
SolutionRead-only · Live Preview

Technical Explanation

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.

Interview Criteria

Trigger detection

Regex correctly identifies '/' at start or after whitespace

Caret-aware splice

Insert places command at trigger pos, drops partial query

Keyboard nav

↑↓ cycles, Enter selects, Esc closes; preventDefault used

Mouse selection

Click inserts without losing textarea focus

Filter UX

Live filter as user types, empty state when no match

Time Checkpoints

0–3 min

0–3 min: Clarify trigger rules, command shape, keyboard shortcuts

3–8 min

3–8 min: Scaffold textarea + command list data + popup styling

8–13 min

8–13 min: Detect trigger via regex, capture trigger position in ref

13–18 min

13–18 min: Filter list by query, render menu, highlight active item

18–23 min

18–23 min: Wire keyboard nav (↑↓/Enter/Esc) with preventDefault

23–28 min

23–28 min: Implement splice + caret restore; mousedown selection; empty state

Streak

0 days

Last active: Sign in to track

Progress

0%

0/0 solved

Submitted

0

Solutions pushed to review history.