Build a Mention Input: @-Tagging like Twitter and Slack

“Build a mention input” is a classic frontend machine-coding problem. You type @ and a dropdown of people appears; pick one and a styled “chip” replaces your typed text. Used in Slack, Twitter/X, GitHub, Linear, Notion. The interview question probes whether you can manage cursor-relative state, async data, and keyboard accessibility under time pressure.

Requirements (clarify these)

  • Trigger character: just @, or also # for channels and : for emoji?
  • Async search or local list?
  • Multi-line input (textarea) or single line?
  • Plain text output, or rich text with mentions as styled tokens?
  • Keyboard navigation expectations (arrows, Enter, Escape, Tab)?
  • Accessibility: combobox ARIA pattern

Architecture

Three components:

  1. Input layer: a contenteditable or textarea that holds the user’s text
  2. Trigger detector: watches input changes, finds the active @query, exposes (query, position)
  3. Suggestion popup: positioned near the caret, shows results, handles selection

Detecting the trigger

On every input event, look at the text before the caret. Find the last @ that is preceded by whitespace or start-of-string. If found and there’s no whitespace between it and the caret, you have an active query (the substring after the @).

function detectMention(text, caretPos) {
  const before = text.slice(0, caretPos);
  const match = before.match(/(^|s)@(S*)$/);
  if (!match) return null;
  return { query: match[2], start: caretPos - match[2].length - 1 };
}

Positioning the popup

For textarea: use a hidden mirror div with the same styles, write the text up to the caret, append a span, measure the span’s position with getBoundingClientRect(). For contenteditable: use window.getSelection().getRangeAt(0).getBoundingClientRect(). The popup is portaled to document.body to escape any overflow:hidden parents.

Debounce by 100–200 ms. Cancel in-flight requests when a new query starts (AbortController). Show a loading state. Handle empty results with a “No matches” placeholder.

Keyboard navigation (the test of seniority)

  • ArrowUp/ArrowDown: move highlighted item; preventDefault to keep caret still
  • Enter: insert highlighted item; preventDefault to avoid newline in textarea
  • Tab: same as Enter (Slack/Twitter-like)
  • Escape: close popup, do not insert
  • Any other key: typed normally, popup updates

Junior candidates usually forget the preventDefault on Enter and the input gets a newline. Don’t.

Inserting the mention

For plain-text output: replace @query with @username (note trailing space). For rich-text output: replace with a styled token (a span with a data attribute) that is non-editable as a unit. Pressing Backspace on it should remove the whole token, not just one character.

Accessibility (the senior signal)

Implement the WAI-ARIA combobox pattern:

  • Input has role="combobox", aria-expanded, aria-controls="listbox-id", aria-activedescendant="option-id"
  • Popup has role="listbox"
  • Each item has role="option" and a unique id
  • Focus stays in the input; aria-activedescendant announces the highlighted item

Test with a screen reader. If it announces “username, 1 of 5, in mention list” you’ve done it right.

Edge cases interviewers love

  • User types @ mid-word (e.g., email address) — should not trigger
  • User pastes a name into the dropdown query — should still search
  • Multiple mentions in one input
  • IME composition (Japanese, Chinese, Korean) — do not trigger during composition
  • Mobile virtual keyboard (the popup must stay visible above the keyboard)

Frequently Asked Questions

Should I use a library?

For interview, no — implement from scratch. For production, consider Tiptap’s mention extension, downshift, or react-mentions, depending on whether your input is rich or plain text.

How do I handle very long lists?

Virtualize with react-window or similar. Most mention dropdowns cap at 10–20 items so this rarely matters in practice.

What about emojis (:smile:) or commands (/help)?

Same architecture — different trigger character, different result source. Generalize the detector to take a trigger and a search function.

Scroll to Top