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

Source: https://www.techinterview.org/post/3233475297/build-mention-input-at-tagging-twitter-slack/
Updated: 2026-06-30 · techinterview.org

"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-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.
