# Build a Slash Commands Menu (Notion-Style)

Source: https://www.techinterview.org/post/3233475216/build-slash-commands-menu/
Updated: 2026-07-26 · techinterview.org

Slash commands (type "/" to open a menu of options) are a UX pattern popularized by Notion and now standard in modern editors ([Linear](/companies/linear/), Reflect, Tana, ClickUp). The interview tests whether you understand the trigger detection, the menu positioning, and the integration with rich-text editors.

## Functional requirements

- Type "/" at start of line opens menu

- Continued typing filters options

- Arrow keys to navigate

- Enter to select; Escape to dismiss

- Selected command inserts content (heading, list, code block, etc.)

## Architecture

Three pieces:

- Editor with cursor position tracking. You need the exact caret offset on every input event — this is what tells you whether "/" landed at line start and where to anchor the menu. In contenteditable editors, read it from the Selection/Range API rather than trusting string indexes.

- Trigger detection: was "/" typed at line start? Keep this as a pure function of editor state so it is easy to test against edge cases. Interviewers often push on what counts as "line start" — after a heading, inside a list item, or right after a soft line break.

- Command menu (similar to combobox / command palette). Treat it as a controlled listbox: it owns the highlighted index and filter query, while the editor owns the text. Reusing combobox accessibility patterns (aria-activedescendant, roving focus) saves you from reinventing keyboard handling.

## Trigger detection

On every keystroke in the editor:

- Check if cursor is at line start (no preceding non-whitespace). Walk back from the caret to the previous newline; if you only hit spaces or tabs, treat it as line start. Notion allows "/" after other text too, so clarify with the interviewer whether the trigger is line-start-only or anywhere.

- Check if last char typed is "/". Bind this to the input event, not keydown, so IME and autocomplete insertions are caught. Compare the character just before the caret rather than the raw key, which handles paste and composition correctly.

- If yes, open menu. Store the "/" position as an anchor so you know later how much text to delete on selection. Opening the menu should not steal focus from the editor — the caret stays in the text.

- Track input characters after "/" for filtering. Capture the substring between the anchor and the caret on each keystroke; if the user deletes back past the "/", close the menu.

Edge cases: paste containing "/", "/" in the middle of a word.

## Menu positioning

Menu appears near the cursor. Use Floating UI:

- Reference element: cursor position (computed from editor). Since the caret is not a DOM node, build a virtual reference from its bounding rect (getClientRects or the editor's coordsAtPos). Re-measure on scroll and resize so the menu tracks the caret.

- Floating element: the menu. Render it in a portal at the document root to avoid clipping by overflow:hidden ancestors, and give it a fixed max-height with internal scrolling for long command lists.

- Flip if not enough room below. Floating UI's flip and shift middleware handle this — flip moves the menu above the caret near the viewport bottom, shift nudges it sideways to stay on screen.

For ProseMirror / Tiptap editors, use the editor's built-in coordinate API.

## Filtering

As user types more characters, filter commands:

- "/he" → matches Heading 1, Heading 2, Heading 3. Match against the command name plus its aliases, and rank exact prefix matches above mid-string matches so the most likely command sits at the top.

- "/code" → matches Code Block, Inline Code. Debounce or memoize the match if the command list is large, and always keep one item highlighted so Enter has a clear target.

Use fuzzy match (command-score, Fuse.js) for robust filtering.

## Insertion

When user selects a command:

- Delete the "/" and any typed filter text. Use the stored anchor to remove the exact range in a single transaction, so undo reverts both the deletion and the insert together.

- Insert the appropriate node (heading, list, etc.). In ProseMirror this is a node replacement, not a string edit — replace the range with the new block node using a schema-valid transaction.

- Position cursor correctly within the new node. Drop the caret inside the new node (empty heading text, first list item) so the user keeps typing without an extra click.

For a heading: replace the line with a heading node.

## Categories

Group commands:

- Basic: text, heading, list. These are the highest-frequency commands, so keep them at the top and reachable with one or two filter characters.

- Media: image, video, embed. These usually open a secondary flow (file picker, URL prompt) rather than inserting immediately — mention that two-step interaction.

- Database: inline DB, link to DB. Product-specific commands that show why the list must be data-driven and extensible rather than hardcoded.

- Advanced: code, math, callout. Lower-frequency power features; grouping them keeps the common commands uncluttered while still discoverable by search.

Show category headers in the menu.

## Aliases and keywords

"todo" should match the to-do command even though command is named "Task list":


```
{ name: 'Task list', keywords: ['todo', 'checklist', 'check'] }
```


## Integration with rich-text editor

Tiptap and ProseMirror have plugin systems:

- Suggestion plugin from Tiptap handles trigger detection. It watches for your trigger character, tracks the query range, and gives you lifecycle hooks (onStart, onUpdate, onKeyDown, onExit) so you only write the menu UI.

- You provide command list and onSelect handler. onSelect receives the editor range and the chosen command, letting you run the replacement as one chained command; keep the list serializable so it is easy to test and extend.

For custom editors, you build the trigger logic.

## Mobile

"/" on mobile keyboards is awkward to type. Most apps:

- Provide a "+" button on mobile that opens the same menu. Reuse the identical command list and selection logic so behavior stays consistent across input methods.

- Floating action button at bottom. Keeps the trigger reachable with a thumb and avoids relying on a hard-to-reach "/" key.

- Long-press selection bar with insert options. Surfaces block actions in the native selection context, matching how users already interact with text on touch devices.

## Common antipatterns

- "/" triggers menu in the middle of words (false positive). Guard the trigger by checking the character before "/" is whitespace or line start, or the menu fires while someone types a URL or file path.

- Menu does not close when cursor moves away. Close on blur, selection change, and clicks outside; a stuck menu covering text is an immediate red flag to interviewers.

- Inserting command does not delete the "/" prefix. Leaving "/heading" behind next to the new node is the most common bug — anchor tracking prevents it.

- No mobile alternative. If "/" is the only entry point, the feature is unusable on touch keyboards where the key is buried behind symbol layers.

## Frequently Asked Questions

### How does Notion handle when "/" is in a code block?

Code blocks have their own keyboard handling; slash menu disabled there.

### Can I implement slash commands without a rich-text editor?

Yes, for plain textareas, but the insertion logic is plain text. Most modern apps use ProseMirror or Tiptap-based editors for richer behavior.

### What if the user wants to type "/" literally?

Press Escape to dismiss the menu, then continue typing. Or type "/" at non-line-start (no menu trigger).
