“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:
- Input layer: a contenteditable or textarea that holds the user’s text
- Trigger detector: watches input changes, finds the active
@query, exposes (query, position) - 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.
Async search
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-activedescendantannounces 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.