cf.completefrontend
My progress
SYSTEM DESIGN · #30

Design an @-mention input

A message box where typing @ opens a list of people, and picking one inserts a mention that notifies them. The hard parts are knowing when a mention starts, placing the popup at the caret, keeping mentions as tokens with ids instead of plain text, deleting them as one piece, and working with mobile keyboards and screen readers.

The question

Design the message composer's @-mention feature for our team chat. When someone types @ followed by a few letters, show matching people and channels, and when they pick one, insert a mention that notifies that person when the message is sent.

Walk me through where the text lives, how you detect that a mention is starting, where the popup appears, how mentions are stored so renames don't break them, how editing and deleting them works, and how it behaves on phones and with screen readers.

Real-life examples: @-mentions in Slack messages, @-mentions in GitHub comments, Mentions in Jira and Linear issues, @ for people, pages and dates in Notion, Tagging friends in Facebook posts, #hashtags and /commands that use the same mechanics

Requirements: what to ask before drawing anything

An @-mention input is a text box where typing @ starts a search for a person. A popup lists matches, you pick one, and the text box now holds a mention: a highlighted name that points to a real user. When the message is sent, that user gets a notification. You've used it in Slack, GitHub comments, Jira, Linear, Notion, and when tagging friends on Facebook.

The same mechanics power other triggers. # finds channels or hashtags, / opens commands, and : opens emoji. So the real component is a trigger engine: watch the text, notice a trigger character, search a source, and insert a result. Mentions are the hardest case because the result is a reference to a person, not just text.

Several neighbours are already covered in other guides, so this one links to them instead of repeating them. The rich text editor guide covers contenteditable, the selection model, and typing in other scripts through an input method editor (IME). The autocomplete guide covers search, caching, and ranking in depth.

The command palette guide covers the combobox keyboard model, the tag input guide covers chips, and the Slack guide shows where the composer sits in a full chat client. Here we focus on what is special about mentions.

Ask these questions first. Each answer changes the design. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
Plain text or rich text messages?Rich text: bold, links, code, and mentions, like Slack.Mentions become nodes in an editor document, not characters in a textarea.
Which triggers?@ for people and groups, # for channels, / for commands at the start of a message.A trigger config with its own rules and data source per character.
How many people can be mentioned?Up to 50,000 people in a workspace; channels usually have 5 to 500 members.Members of the current channel can be searched locally; everyone else needs server search.
Can names contain spaces?Yes: display names like "Ana Rodriguez" and "Li Wei".The query can't simply end at the first space.
What if someone is renamed?Old messages must still point to the right person and show the new name.Store an id, render the label from current user data.
Who gets notified?The mentioned users, plus everyone for @channel and active people for @here.The server decides from ids in the saved message, never from the text.
Which devices?Desktop browsers and phones; assume close to half of all messages are written on a phone.Must survive Android keyboards, autocorrect, and the on-screen keyboard covering the popup.
Accessibility target?WCAG 2.2 AA.The popup must be reachable and announced without moving focus out of the editor.

Functional requirements

  • Typing @ at the start of a word opens suggestions; typing more letters filters them.
  • Arrow keys move through suggestions, Enter or Tab inserts, Escape closes, and clicking a row inserts too.
  • An inserted mention is one unit: it shows the person's name, highlighted, and deletes as a whole.
  • Sent messages keep pointing to the right person after renames, and show the current name.
  • Copy and paste keeps mentions inside the app and turns them into readable text elsewhere.
  • Groups like @channel and @here, channels with #, and commands with / work the same way.

Non-functional requirements

  • Fast: local suggestions appear within one frame of a key press (under 16 ms); server suggestions within about 200 ms on a normal connection.
  • Never blocks typing: a slow search must not delay or drop characters.
  • Correct on every keyboard: physical keyboards, Android and iOS virtual keyboards, and IME input for Chinese, Japanese and Korean.
  • Accessible: the suggestions, the active row, and the inserted mention are all announced to screen readers.
  • Safe: names are user content, so they are always rendered as text, never as HTML.

Out of scope: the full editor (toolbar, formatting, undo), the notification system itself, and the search backend's ranking model. We'll define how the input talks to them, not how they work inside.

Check your understanding · Exercise 1

Which answers change the design of the mention input itself the most? Select all that apply.

What makes this hard

On a whiteboard, a mention input looks like an autocomplete with a different trigger. It isn't. Five things make it harder, and each one drives a later chapter.

  1. The query lives inside other text. An autocomplete owns its whole input. Here the query is a slice in the middle of a sentence: "thanks @ana for the fix". The component must find where that slice starts and ends on every key press, and must not fire on ana@example.com or a URL.
  2. The popup follows the caret, not the box. A dropdown sits under its input. A mention popup sits under the @, which might be on line 4 of a growing message, near the right edge, or under the phone keyboard. Browsers have no direct "where is the caret" API for a textarea.
  3. The result is a reference, not text. Picking "Ana Rodriguez" must store the id U123, not just the string. Names change, two people can share a name, and notifications go to ids. Plain text loses all of that.
  4. The token must behave as one piece. The caret should never land in the middle of a mention. Backspace removes it whole. Copy and paste must keep it. Editing its letters must not leave a half-name that still pings someone.
  5. Keyboards lie. On Android, many virtual keyboards report keyCode 229 for every key while a word is being composed, and autocorrect can replace a whole word at once. Code that listens for a keydown of @ misses mentions on those phones.

Notice what is not on the list: the search itself. Finding "Ana" among 50,000 people matters, but it is the autocomplete problem with an extra filter, and we reuse that design. The new work in this problem is in the text: detecting, anchoring, storing, and deleting.

The parts of the screen

The figure below names the parts we'll design. The composer is the editable area. The active query is the text from the trigger to the caret, shown with a dotted underline while the popup is open.

The suggestion popup is a list of people anchored to the trigger. Mention tokens are the inserted names, styled as pills. Below the list, a hint row tells the user which keys work, and a muted note appears on a row when that person isn't in the channel.

Wireframe of a chat composer in the channel release. At the bottom, the composer holds two lines: the word thanks and a highlighted mention token Li Wei, then the text and @ana with the caret after it, underlined as the active query. Above the composer, because there is no room below, the suggestion popup lists Ana Rodriguez from Design as the active row, Ana Silva marked not in channel, and the group @analytics-team notifying 14 people, with a hint row: arrows, Enter, Esc. Notes on the right name the popup placed above, near the @, the active row reached through aria-activedescendant, the not in channel warning, the token holding an id, and the dotted active query.#releaseLi Wei: build 412 is greenAna Rodriguez, DesignAna Silva, not in channel@analytics-team: notify 14arrows, Enter, Escthanks@Li Weiand @ana|Enter sends when the popup is closedpopup: above,near the @active row viaactivedescendantnot in channel:muted warningtoken: one unit,holds id U42active query,dotted underline
The popup opens above the line because the composer sits at the bottom of the screen. Focus never leaves the composer: the active row is pointed at, not focused.

Think first

A #hashtag on a social site and a #channel link in Slack both start with #. Should both be stored as tokens with ids?

Where the text lives: textarea, editor nodes, or raw contenteditable

The first real decision is the element the user types into. Everything else (how we find the caret, how tokens delete, how IME works) follows from it.

The naive approach is a plain textarea that stores @Ana Rodriguez as text. It's close to what GitHub does: comments are Markdown, a mention is the text @login, and the server turns it into a link when rendering. GitHub accepts the cost because a login is a unique handle that people rarely change, and when someone does rename, the old login becomes free for anyone to claim.

Try the same in our chat, where display names change often. Ana is renamed to "Ana Silva", and every old message now says "@Ana Rodriguez", which points to nobody, or worse, to a new person who later takes that name. Two people called "Li Wei" can't be told apart at all. Text alone can't carry an id.

Decision Which editing surface holds the message and its mentions?

Textarea with a highlight overlay (react-mentions style)

A real textarea holds the plain text the user sees, like "hi Ana Rodriguez".

A separate value string holds markup, like hi @[Ana Rodriguez](U123), and the component maps positions between the two.

A highlighter div sits behind the textarea, whose background is transparent. It copies the font, padding and wrapping, and paints a colored box behind each mention's characters.

Pros

  • Native typing, spellcheck, IME, undo, and mobile behaviour all come free from the browser.
  • Small: no editor framework, and the value is a simple string.
  • Works well for plain text fields like comments in forms.

Cons

  • Only plain text: no bold, links, or code inside the message.
  • The overlay must match the textarea pixel for pixel; a different font fallback or scrollbar shifts highlights.
  • A mention isn't really atomic: the caret can land inside it, and the component must detect and repair edits to its letters.

Editor framework with mention nodes (Tiptap, Lexical, ProseMirror)

The editor keeps a document tree; a mention is an inline node like { type: "mention", attrs: { id: "U123", label: "Ana Rodriguez" } }.

The node renders as a non-editable span, so the editor treats it as one character for caret movement and deletion.

A suggestion plugin (Tiptap's @tiptap/suggestion, Lexical's typeahead menu plugin) watches the text before the caret for triggers.

Pros

  • Mentions are real atomic objects with ids, living next to bold, links and lists.
  • The framework already handles selection, IME, paste, and undo, the hard parts covered in the rich text editor guide.
  • Serializing the document to JSON or to a markup string is a tree walk.

Cons

  • A larger bundle: tens of KB gzipped, which is heavy for a small comment box.
  • You work within the framework's model: custom paste and copy rules are plugin code, not simple handlers.
  • Mobile quirks still leak through; frameworks fix many but not all Android keyboard cases.

Raw contenteditable from scratch

A div with contenteditable, and mentions inserted as span elements with contenteditable="false".

Your own code reads the DOM back into a model after each input event.

Pros

  • Full control and no framework bundle.
  • Mentions look and behave like inline elements immediately.

Cons

  • Each browser produces different DOM for the same edit, and IME, undo and paste each become weeks of work.
  • The DOM becomes the source of truth, so bugs corrupt messages in ways that are hard to reproduce.

What we pick: For our rich text chat, use an editor framework with mention nodes. The message already needs formatting, so the framework cost is paid anyway, and real nodes make tokens atomic for free. For a plain text comment field with no formatting, the textarea plus overlay is the right, lighter choice; we'll show its caret technique too. Avoid raw contenteditable unless building the editor is the product.

Two ways to hold the same message, hi Ana. Left, textarea plus overlay: a textarea on top holds the real text with a clear background, an overlay div below holds the same text and paints boxes behind names, and the value string is hi @[Ana](U1), with positions mapped between plain text and markup. It drifts if any text metric differs. Right, editor plus mention node: a paragraph node has two children, a text node hi and a mention node with id U1, rendered as a span with contenteditable false that the caret jumps over. It is atomic by design.Textarea + overlaytextarea (top)real text, clear backgroundoverlay div (below)same text, boxes behind namesvalue: hi @[Ana](U1)positions mapped plain to markupdrifts if any metric differsEditor + mention nodeparagraphtext: himention U1span contenteditable=falsecaret jumps over itatomic by design
The overlay fakes a token by painting behind characters, so the editor must guard every edit. The editor makes the token a real node, rendered as contenteditable="false", so it is one unit by construction.

How the overlay keeps in step

If you pick the textarea route, the overlay is the fragile part. The textarea and the highlight div must have identical font, letter-spacing, padding, border-width, line-height, white-space: pre-wrap and word-wrap, the same width, and the same scroll offset. Any difference and highlights drift a few pixels per line, which is visible by line 3. Sync scrollTop on the scroll event, and re-measure on resize, because a scrollbar appearing makes the text area narrower and changes wrapping.

The editor route has no overlay: the mention is in the DOM, styled directly. This is one more reason editors win for chat, where messages often grow to many lines and scroll.

Check your understanding · Exercise 2

A team builds a plain text "Add a note" field on an invoice page, where notes can @-mention coworkers. Which choices fit? Select all that apply.

Detecting the trigger and the query

The engine must answer one question after every change: is the caret currently inside a mention query, and if so, which trigger, where does it start, and what has been typed? Everything else, the popup, the search, the insert, waits on that answer.

The naive approach listens for keydown with event.key === "@", opens the popup, and collects letters until a space. It fails four ways. It opens on ana@example.com, and it closes on the space in "@Ana Ro", so you can't search by full name.

It also never opens on Android keyboards that report key as Unidentified while composing. And it misses an @ that arrives by paste, autocorrect, or by moving the caret back into an earlier @an.

Read the text before the caret, not the keys

The fix is to stop caring how text arrived. After every input event and every selectionchange, take the text from the start of the current block up to the caret, and scan it backwards. This is a pure function of text, so it works the same for typing, paste, autocorrect, IME, and caret moves, and it is easy to unit test. In Tiptap and Lexical the suggestion plugins already work this way: they look at the text before the cursor on every editor update.

ts
type Rule = { maxWords: number; startOnly?: boolean };
const RULES: Record<string, Rule> = {
  "@": { maxWords: 3 },
  "#": { maxWords: 1 },
  "/": { maxWords: 1, startOnly: true },
};
const STOP = /[\n,;:!?()[\]{}]/u;

export function findQuery(before: string) {
  const min = Math.max(0, before.length - 64);
  for (let i = before.length - 1; i >= min; i--) {
    const ch = before[i];
    const rule = RULES[ch];
    if (!rule) {
      if (STOP.test(ch)) return null;
      continue;
    }
    const prev = before[i - 1] ?? "";
    if (prev && !/[\s"'(]/u.test(prev)) continue; // a@b.com, x.com/@ana
    if (rule.startOnly && before.slice(0, i).trim() !== "") return null;
    const query = before.slice(i + 1);
    if (query.startsWith(" ") || query.includes("  ")) return null;
    if (query.split(" ").length > rule.maxWords) return null;
    return { trigger: ch, start: i, query };
  }
  return null;
}
A pure trigger finder: scan back at most 64 characters from the caret.

Walk through the rules it encodes:

  • Word boundary: the trigger counts only at the start of the text or after a space, a quote, or an opening bracket. In ana@example.com the @ follows a letter, so it's skipped. In x.com/@ana it follows a slash, so it's skipped.
  • Stop characters: a newline, comma, colon or similar between the caret and the trigger means the user has moved on. The code checks for a trigger first, so a character can still be a trigger (say : for emoji) if you add a rule for it.
  • Spaces in names: one space is allowed between words, up to a word limit per trigger (3 for people, 1 for channels). Two spaces in a row, or a space right after the trigger, ends the query.
  • Position rules: / commands only count at the start of the message, so "either/or" never opens commands.
  • A short look-back: scanning only 64 characters keeps the work tiny even in a 10,000-character message.

Editor frameworks expose the same rules as settings. Tiptap's suggestion utility has allowedPrefixes (the characters allowed before the trigger, a space by default), allowSpaces, and startOfLine. If you use a framework, you configure these instead of writing findQuery yourself, but the decisions are the same.

Spaces need one more rule that the function can't know on its own: keep the query only while it still matches someone. "@Ana Ro" stays open because "Ana Rodriguez" matches. "@Ana thanks" matches nobody, so the popup closes, and the text stays plain. Without this rule the popup would chase the user across the next three words of every sentence.

Sample

Query open. Trigger @ at index 7, query "an", 3 matches: Ana Rodriguez, Ana Silva, Anand Kumar. The popup anchors at index 7.

Trigger lab. Text before the caret: thanks @an. Query open. Trigger @ at index 7, query "an", 3 matches: Ana Rodriguez, Ana Silva, Anand Kumar. The popup anchors at index 7.text before the caretthanks_@an@Ana Rodriguez@Ana Silva@Anand Kumartrigger @ start 7 query "an"
Each box is one character before the caret (the last 36). The trigger is green, the query is highlighted, and the popup sketch starts under the trigger, clamped to the edge.

The query as a small state machine

The popup has four states: idle (no query), open (a query with results, or loading), dismissed (the user pressed Escape on this trigger), and committed (a result was inserted). Dismissed is the state people forget. Without it, pressing Escape closes the popup, the next letter runs findQuery again, and the popup reopens. So remember the dismissed trigger's position, and stay quiet until the caret leaves that query or a new trigger is typed.

State machine for a mention query with four states. Idle: no query. From idle, typing a trigger at a word boundary goes to open. Open shows results or loading. From open, a stop character or a query with spaces that matches nobody goes back to idle. From open, Enter, Tab or a click goes to committed, where the token is inserted and the machine returns to idle. From open, Escape goes to dismissed, which stays quiet until the caret leaves that query or a new trigger is typed, then returns to idle.idleno queryopenresults or loadingcommittedtoken inserteddismissedquiet on this @@ at boundarystop char, no matchEnter, clickback to idleEscapecaret leaves, or a new @
Dismissed is the state people forget. Without it, the next letter after Escape runs detection again and the popup jumps back open.

Check your understanding · Exercise 3

With the rules above and the caret at the end of each text, which ones open a people query? Select all that apply.

Placing the popup at the caret

Now we know a query starts at index 7. Where on screen is index 7? The popup should appear just under the @, left-aligned with it, so the eye doesn't travel. That's easy to say and awkward to do, because the browser gives element boxes, not character boxes.

The naive approach anchors the popup to the bottom-left corner of the whole input. In a one-line box that's fine. In a six-line message where the @ is near the right edge of line 4, the list appears two lines below and 500 pixels to the left of where the user is looking. Users on wide screens often don't notice it at all.

Anchor to the trigger, not the moving caret

Before measuring anything, choose what to measure. If the popup follows the caret, it slides right with every letter typed, which is distracting. Keep it still instead: measure the box of the trigger character once when the query opens, and again only on scroll, resize, or a line wrap. Measuring a real character also avoids a known trap: a collapsed range (a bare caret) can report an empty rectangle at the start of an empty line, while a one-character range always has a box.

Decision How do we get the screen position of the trigger character?

Mirror div (for a textarea)

Create a hidden div that copies the textarea's font, padding, border, width, and wrapping styles.

Put the text before the trigger in it, then a span with the rest of the text.

The span's offset inside the mirror, plus the textarea's box, minus its scroll, is the trigger's position.

Pros

  • The only way that works for a textarea or input, which have no per-character DOM.
  • Small, well known code; the textarea-caret-position library popularised it.

Cons

  • Must copy every text metric exactly, or the result drifts.
  • Creates layout work on each measure, so do it once per query, not per key.

Range rectangle (for contenteditable)

Make a Range that covers the trigger character in its text node.

Call range.getBoundingClientRect() for its box in viewport coordinates.

Editors wrap this: ProseMirror has view.coordsAtPos(pos), and Tiptap's suggestion passes a clientRect function to your popup.

Pros

  • Exact, because it measures the real rendered text.
  • Cheap: no extra DOM.

Cons

  • Only works where the text is in the DOM, so not for a textarea.
  • A range that crosses a line wrap or mixed left-to-right and right-to-left text has several boxes, and the bounding box covers them all; use getClientRects() there.

CSS anchor positioning

Give an element anchor-name, and position the popup with position-anchor and position-area, with position-try-fallbacks for flipping.

Pros

  • Flipping and following scroll happen in CSS, without JavaScript measuring.
  • Shipped in Chromium 125, Safari 26 and Firefox 147, so current versions of all major browsers have it.

Cons

  • An anchor must be an element. A caret, a Range, or a character inside a textarea is not one, so it can't be anchored directly.
  • In an editor it works only if the query is wrapped in a real element, for example a decoration span around the active query (a decoration is markup the editor draws around text without changing the document; Tiptap's suggestion already renders one), and older browsers still need a JavaScript fallback.

What we pick: Measure with a range rectangle in the editor (through the framework's coordinates helper) and with a mirror div in the textarea version. Feed the result as a virtual reference to the same positioning code the dropdown-select guide uses (Floating UI, the positioning library, accepts any object with getBoundingClientRect). CSS anchor positioning is a good upgrade later, anchoring to a decoration span around the query, but it can't replace measuring for a plain textarea.

ts
const COPY = ["direction", "boxSizing", "width", "fontFamily", "fontSize",
  "fontWeight", "fontStyle", "lineHeight", "letterSpacing", "wordSpacing",
  "textTransform", "tabSize", "paddingTop", "paddingRight", "paddingBottom",
  "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth",
  "borderLeftWidth", "borderStyle"] as const;

export function charRect(ta: HTMLTextAreaElement, index: number) {
  const cs = getComputedStyle(ta);
  const mirror = document.createElement("div");
  for (const p of COPY) mirror.style[p] = cs[p];
  Object.assign(mirror.style, {
    position: "absolute", visibility: "hidden", top: "0", left: "-9999px",
    whiteSpace: "pre-wrap", overflowWrap: "break-word", overflow: "hidden",
  });
  mirror.textContent = ta.value.slice(0, index);
  const mark = document.createElement("span");
  mark.textContent = ta.value.slice(index) || "."; // keeps wrapping identical
  mirror.append(mark);
  document.body.append(mirror);
  const box = ta.getBoundingClientRect();
  // offsetTop is measured inside the border, the box outside it
  const left = box.left + parseFloat(cs.borderLeftWidth) + mark.offsetLeft - ta.scrollLeft;
  const top = box.top + parseFloat(cs.borderTopWidth) + mark.offsetTop - ta.scrollTop;
  const height = mark.getClientRects()[0]?.height ?? 20;
  mirror.remove();
  return { left, top, width: 0, height };
}
Mirror-div measurement for a textarea. Run once when a query opens.

Flipping, clamping, and the phone keyboard

The popup prefers to open below the trigger line, but a chat composer usually sits at the bottom of the screen, so there's rarely room. The positioning code measures space below and above and flips to above when the list (say 6 rows of 36 px, about 220 px) doesn't fit. It also clamps horizontally, so a trigger near the right edge shifts the popup left instead of cutting it off.

On phones, the on-screen keyboard covers the bottom of the page without always resizing the layout. Measure available space against window.visualViewport (its height and offsetTop), and listen to its resize and scroll events. Otherwise the popup opens "below" into space hidden behind the keyboard.

Placing the popup in three steps. Step 1: a textarea holds two lines, ship it today, and, then ping @an with the caret. Step 2: a hidden mirror div with the same styles holds the same text, with the @an part wrapped in a span; top equals the textarea box top plus its top border plus the span's offsetTop, minus scrollTop. Step 3: on a phone, the on-screen keyboard covers the bottom of the viewport, the space below the trigger line is under 220 px, so the popup with Ana Rodriguez and Anand Kumar flips to above the @an line.1. textareaship it today, andping @an|2. hidden mirror divship it today, andping@antop = box.top + border + span.offsetTop - scrollTop3. place and flipAna RodriguezAnand Kumarping @an|space below under 220 pxon-screen keyboard
A textarea has no per-character boxes, so the mirror div recreates its layout to measure one. Space is checked against visualViewport, which shrinks when the keyboard opens.

Check your understanding · Exercise 4

Users report that the mention popup in a textarea-based comment box appears about 30 px too low after they type a long comment. What are likely causes? Select all that apply.

Suggestions: who to show, in what order

Searching people is the autocomplete problem, and the autocomplete guide covers debouncing, caching by prefix, and dropping stale responses in depth. What changes for mentions is context. In a channel of 12 people, typing @a almost always means one of those 12, not one of 50,000 people named something with an A.

The naive approach sends every query to a workspace-wide search endpoint and shows the results alphabetically. Three problems: each key press waits about 150 ms for the network, so the first letters feel slow; "Aaron from Finance" ranks above "Ana" who is in this thread; and a poor connection leaves the popup empty.

Decision Where do suggestions come from?

Server search only

Every query, debounced, calls GET /mentions/search?q= and shows the result.

Pros

  • Always complete and fresh.
  • No member list kept on the client.

Cons

  • Every letter waits for the network.
  • Empty popup offline or on slow links.
  • Context (this channel, this thread) must be sent and ranked on the server.

Local list only

Load the channel's members when the composer opens and filter them in memory.

Pros

  • Instant, under a frame for a few hundred names.
  • Works offline.

Cons

  • Can't find people outside the channel, which is how you invite someone into a conversation.
  • Large channels of 10,000 or more make the list heavy.

Local first, then merge server results

Filter the local context (thread participants, channel members, recent contacts) on every key press.

In parallel, debounce about 150 ms and ask the server for workspace matches.

Merge: local rows keep their places at the top, server rows are added below without duplicates.

Pros

  • The first letters show useful people instantly.
  • Still finds anyone in the workspace.
  • Degrades well: offline, local results still work.

Cons

  • Two sources to merge and rank.
  • Rows appearing below the active one must not move the user's highlighted row.

What we pick: Local first, then merge server results. Showing local matches instantly is what makes the feature feel fast, and the server fills the long tail. The one rule for merging: never reorder rows above or at the active row once shown, only append. Otherwise a user pressing Enter at the same moment a response lands picks the wrong person.

Ranking signals

SignalWhy it mattersWhere it comes from
Took part in this threadReplies usually mention people already talking.The thread's messages, already loaded.
Member of this channelMentioning a member never needs an invite.Channel member list, cached.
You talked recentlyPeople mention the same 10 to 20 colleagues most of the time.A small recent-contacts list per user.
Match quality"ana" at the start of the first name beats "ana" in the middle of a surname.The local matcher and the server.
Active or awaySlightly favours people who can answer now.Presence data, if already loaded.

Groups sit in their own section: @here, @channel, and user groups like @design-team, each showing how many people it will notify, for example "@channel: notify 1,240 people". That number is a cheap guard against an accidental ping to the whole company. People outside the channel get a muted note on their row, like "Not in #release", so the user knows a mention won't reach them unless invited. Slack goes one step further and, after sending, offers to invite such a person; we design for both.

ts
type Person = { id: string; name: string; inChannel: boolean };

export function merge(shown: Person[], active: number, server: Person[]) {
  const seen = new Set(shown.map((p) => p.id));
  const fresh = server.filter((p) => !seen.has(p.id));
  // rows up to and including the active one never move
  const locked = shown.slice(0, active + 1);
  const rest = [...shown.slice(active + 1), ...fresh];
  rest.sort((a, b) => Number(b.inChannel) - Number(a.inChannel));
  return [...locked, ...rest].slice(0, 20);
}
Merging without moving what the user already sees.

The figure follows one query from key press to screen, including what happens when the server answers late or fails. Each request carries an AbortController, and a new query aborts the old one, as the request races lesson explains. A failure is quiet: local results stay, and a small "Can't search everyone right now" row replaces the spinner.

Sequence diagram of suggestions with five columns: user, editor, controller, server, and popup. The user types @an. The editor reports the query an. The controller filters local members at once and renders 3 local rows in the popup, then, after a 150 ms debounce, sends GET q=an to the server. The user types d. The editor reports the query and. The controller aborts the request for an and sends GET q=and. Failure path: the server answers 503. The controller keeps the local rows and adds a quiet note saying it can't search everyone right now.usereditorcontrollerserverpopuptypes @anquery: an3 local rowsGET q=antypes dquery: andabort; q=and503local + note
Local rows appear in the same frame as the key press. The server can only add rows below them, and when it fails, the popup keeps working with what it has.

Check your understanding · Exercise 5

A user types @jo, sees "Joana (in channel)" highlighted, and presses Enter just as the server returns "Jo Park", who matches better. What should happen? Select all that apply.

Storing mentions as tokens, not text

This is the heart of the problem. When the user picks Ana, what exactly do we store, send, and save? The answer must survive renames, duplicate names, deleted accounts, copy and paste, and users who type angle brackets on purpose.

Inside the editor, a mention is a token: an inline node with an id, a kind, and a label. The id is the truth; the label is a snapshot of the name at the time of writing, used only as a fallback.

ts
type MentionKind = "user" | "channel" | "group" | "special";

type Inline =
  | { type: "text"; text: string; marks?: ("bold" | "code")[] }
  | { type: "mention"; kind: MentionKind; id: string; label: string };

type Paragraph = { type: "paragraph"; content: Inline[] };
type MessageDoc = { type: "doc"; content: Paragraph[] };

const doc: MessageDoc = {
  type: "doc",
  content: [{ type: "paragraph", content: [
    { type: "text", text: "thanks " },
    { type: "mention", kind: "user", id: "U123", label: "Ana Rodriguez" },
    { type: "text", text: " for the fix" },
  ] }],
};
The editor's view of a message with one mention.

Decision How is the message sent to the server and stored?

Plain text with @name

Send "thanks @Ana Rodriguez for the fix"; the server finds names in the text.

Pros

  • Simplest; any client can write it.
  • Readable in logs and exports.

Cons

  • Breaks on renames and duplicate names.
  • The server must guess where a name with spaces ends.

Inline markup with ids

Mentions become escaped markers inside a string: Slack uses <@U123>, <#C456> and <!here>; Discord uses <@123>; react-mentions defaults to @[Ana Rodriguez](U123).

Literal <, > and & typed by the user are escaped, so a user can't fake a mention by typing <@U123>.

Pros

  • One string field, easy to store, search, and send through webhooks and bots.
  • Ids survive renames; the label is looked up at render time.

Cons

  • Every client needs a correct parser and escaper.
  • Formatting beyond mentions needs more markup rules.

Structured JSON document

Send the editor's tree, or a stable public version of it. Slack's rich text blocks contain elements like { "type": "user", "user_id": "U123" }.

Pros

  • No parsing or escaping of mentions at all.
  • Room for any future node type: dates, pages, tasks.

Cons

  • Bigger payloads.
  • Older clients and bots need a plain text fallback field.

What we pick: Store the structured document as the source of truth and generate a markup string (<@U123> style) alongside it for search, bots, notifications and old clients. If you only have room for one, inline markup with ids is the better single choice than plain text: it is compact and keeps ids. Never store plain @name for anything that can be renamed.

Data model of one mention in three stages. In the editor document, a paragraph holds three nodes: text thanks, a mention node with id U123 and the label snapshot Ana Rodriguez, and text for the fix. Sent to the server, it becomes a JSON doc, the source of truth, and a markup string, thanks <@U123> for the fix. Rendered later, the user store maps U123 to the current name Ana Silva, so the message shows thanks @Ana Silva for the fix. If the user is deleted, the snapshot is shown greyed.editor documenttext: thanksmention U123text: for the fixlabel snapshot: Ana Rodriguezsent to serverdoc (JSON)source of truthtext (markup)thanks <@U123> for the fixrendered lateruser storeU123: Ana Silvathanks @Ana Silva for the fixdeleted user or no access: show the snapshot, greyed, not a link
The id travels everywhere; the name is looked up at the last moment. The snapshot only matters when the lookup fails.

Render the label from current data

When a message is displayed, each mention looks up its id in the client's user store and shows the current name: Ana renamed to "Ana Silva" shows as "@Ana Silva" in every old message. Never fetch names one by one while rendering: collect all ids in the visible messages and load them in one batched request.

The stored label is the fallback for three cases. The user store hasn't loaded that person yet (show the label, then update). The person was deleted (show the label, greyed, not a link). Or the viewer isn't allowed to see that person, like a member of another organisation in a shared channel.

Copy and paste

  • Copy writes two formats to the clipboard in the copy event. text/plain gets readable text: "thanks @Ana Rodriguez for the fix". text/html gets spans with data attributes, like span data-mention-id="U123", so a paste back into our app restores the token. ProseMirror works the same way: it marks the HTML it copies with a data-pm-slice attribute.
  • Why not a custom type like application/x-mentions+json? setData accepts one, but Safari only shows custom types to pages from the same origin, and other apps ignore them. text/html survives more trips, so it is the usual carrier; a custom type can ride alongside as a bonus.
  • Paste inside the app reads text/html, turns marked spans back into mention nodes, and checks each id against the current workspace. An id from another workspace becomes plain text.
  • Paste from outside (an email, a doc) is plain text. "@Ana" stays text: guessing which Ana was meant would silently notify the wrong person. The user can retype the @ to pick.
  • Pasted HTML is untrusted. Anyone can craft a span claiming to be @channel. The client only shapes the document; the server decides who is notified, from ids it validates.
Insert
Users
Backspace

Try Backspace: the end of the message is a text node.

Stored markup: thanks <@U1> and <@U2> for the fix

Rendered text: thanks @Ana Rodriguez and @Li Wei for the fix

Token lab. The document has 5 nodes: text "thanks "; mention U1, label Ana Rodriguez; text " and "; mention U2, label Li Wei; text " for the fix". Markup: thanks <@U1> and <@U2> for the fix. Rendered: thanks @Ana Rodriguez and @Li Wei for the fix. Screen reader log, newest first: Try Backspace: the end of the message is a text node.document nodes (what the editor stores)thanks_@Ana Rodriguez_and_@Li Wei_for_the_fixscreen reader says (newest first)1. Try Backspace: the end of the message is a text node.
Green boxes are mention nodes, showing the current name; a grey-marked box is a deleted user shown from its snapshot; underscores are spaces. The stored markup never changes when a user is renamed.

Check your understanding · Exercise 6

Ana Rodriguez (U123) is renamed to Ana Silva. Which statements are true for a design that stores <@U123> plus a label snapshot? Select all that apply.

Inserting, editing and deleting tokens

Inserting is one editor transaction: replace the range from the trigger to the caret (@ana) with a mention node, then add a space after it so the user can keep typing. One transaction means one undo step: Ctrl+Z brings back @ana with the popup closed, which is what people expect.

Sequence diagram of inserting a mention, with five columns: user, editor, engine, store (the user store), and live region. The user presses Enter on Ana Rodriguez. The editor's keydown handler checks isComposing is false and passes the key to the engine. The engine asks the editor to replace @ana with a mention node and a space in one transaction. The editor asks the user store for U123; the store already has the name and replies Ana Rodriguez. The editor tells the live region Mentioned Ana Rodriguez, and re-renders with the token and the popup closed.usereditorenginestorelive regionEnternot composingreplace @anaU123?Ana RodriguezMentioned Anare-render
Replacing the query and adding the space happen in one transaction, so one Ctrl+Z undoes the whole insert. If the person isn't in the store yet, the snapshot label shows until the batched lookup returns.

After that, the token must act like a single character. The caret moves over it in one arrow press, never stopping between "Ana" and "Rodriguez". Editors do this with atom inline nodes: ProseMirror and Tiptap mark the mention node atom: true, and Lexical has a text node mode called token whose text can't be edited and is deleted as a whole. In raw HTML the element would carry contenteditable="false".

Decision The caret is right after a mention. What does Backspace do?

Delete the whole token at once

One Backspace removes the entire mention node.

Pros

  • Fast and predictable; it is what many chat composers do.
  • A half-deleted name is impossible.

Cons

  • Easy to remove a long mention by accident, so undo must work and a screen reader should hear what was removed.

Select first, delete on the second press

The first Backspace highlights the token; the second removes it. Any other key cancels the selection.

Pros

  • Hard to delete by accident.
  • Shows clearly that the name is one unit.

Cons

  • Two presses for a common action.
  • The selected state must be visible and announced, or it feels like Backspace is broken.

Turn it back into a query

Backspace replaces the token with its trigger, @, so the user can pick again. This is Tiptap's Mention default: Backspace after a mention leaves the trigger character unless deleteTriggerWithBackspace is set to true.

Pros

  • Fixing a wrong pick is one key: Backspace, then choose again.

Cons

  • To delete a mention fully you press Backspace twice.
  • Surprising for users who expect the name to vanish.

What we pick: For chat, delete the whole token in one press, announce "Removed mention Ana Rodriguez" through a live region (a visually hidden element whose new text screen readers read aloud), and rely on undo. Long documents like Notion pages, where a mention sits among paragraphs, can justify select-first. Make the behaviour a prop, because all three are reasonable and teams disagree.

When someone edits the letters of a mention

In an editor with atom nodes, you can't type inside a token, so this can't happen. In the textarea version it can: the user clicks between "Ana" and "Rodriguez" and types. The safe rule is simple: any change inside a mention's range turns it back into plain text.

Never keep the id on a name the user changed, because "Ana Rodrigu" that still notifies U123 is a confusing lie, and "Ana Rodriguez Smith" might be a different person. Facebook's composer has long offered a friendlier variation: Backspace on a full name shortens it to the first name while keeping the link.

IME and mobile keyboards

Three kinds of input break naive mention code, and the rich text editor guide explains the editor side of each. Here's what they mean for mentions:

  • IME composition. Japanese, Chinese and Korean users type through a composition step, where Enter confirms the converted text. If the mention popup grabs Enter during composition, it inserts a person instead of confirming the word. Check event.isComposing and also keyCode 229, because Safari has long fired that confirming keydown after compositionend, with isComposing already false. Run trigger detection again on compositionend.
  • Android keyboards. Many send keydown with keyCode 229 and key set to Unidentified for every letter while a word is in progress. That is why detection reads the text after input, never the key. The @ is seen in the text even when the key event says nothing.
  • Autocorrect and predictions. iOS and Android can replace a whole word at once, for example changing "@ana" to "@Ana" or adding a space after a suggestion. Depending on the platform this arrives as a composition update or as a beforeinput event with an inputType like insertReplacementText. Since detection is text based, the query updates correctly either way. Turn off the autocapitalize and autocorrect attributes only if testing shows they ruin queries; users still want spelling help in the rest of the message.
ts
function onKeyDown(e: KeyboardEvent, popup: PopupState) {
  if (!popup.open || e.isComposing || e.keyCode === 229) return;
  switch (e.key) {
    case "ArrowDown": popup.move(1); break;
    case "ArrowUp": popup.move(-1); break;
    case "Enter":
    case "Tab": popup.commit(); break;
    case "Escape": popup.dismiss(); break;
    default: return; // letters go to the editor
  }
  e.preventDefault();
}
Popup keys never fire during composition.

Check your understanding · Exercise 7

A Japanese user types @ then たなか and presses Enter to convert it to 田中. The popup is open. What should happen? Select all that apply.

Architecture, data flow and interface

With the hard parts decided, we can draw the pieces. The goal of the boundaries below: the trigger engine never knows where people come from, the popup never knows about the editor, and the server, not the client, decides who gets notified.

PartJobWhy it's separate
Composer (editor host)Owns the document, selection, undo, and the mention node view.The editor framework lives here and nowhere else, so swapping Tiptap for Lexical touches one part.
Trigger engineRuns findQuery on each update and holds the idle, open, dismissed state.Pure logic, unit tested with strings. Shared by @, # and /.
Caret anchorMeasures the trigger character's box and feeds positioning.Different code for textarea and editor, same output shape.
Suggestion controllerCalls each trigger's sources, merges, ranks, aborts stale requests.Owns all async work, so the popup is a plain view.
Suggestion popupRenders rows, groups and warnings; reports clicks and hover.A dumb list that teams can restyle with renderSuggestion.
User storeMaps ids to current names and avatars; batches lookups.Shared with the message list, so renames show everywhere at once.
SerializerTurns the document into JSON, markup like <@U123>, and plain text.Sending, copying and previews all need the same conversion.
AnnouncerA polite live region for result counts and inserted or removed mentions.One place for spoken feedback, so messages don't overlap.
Architecture. In the browser: the Composer, which owns the document and selection, sends the doc to the Serializer on send, and on each update sends text to the Trigger engine. The Trigger engine sends the trigger position to the Caret anchor and the query to the Suggestion controller. The Caret anchor gives the box to the Popup. The controller gets local members from the User store, sends GET requests to the Search API, gives rows to the Popup, and sends messages to the Announcer. On the server: the Serializer posts to the Message API, which checks ids and hands valid mentions to the Notifier.BrowserServerComposerdoc, selectionSerializerdoc, markup, textCaret anchortrigger boxTrigger enginefindQuery, statesPopuprows, warningsSuggest ctrlmerge, abortAnnouncerlive regionUser storeid to nameMessage APIchecks idsNotifierpings peopleSearch APIGET peoplesendqueryrows
The trigger engine and controller are plain logic, so they are tested without a browser. Only the composer knows which editor framework is used, and only the server decides who is notified.

Data flows in two loops. The typing loop stays on the client except for search: editor update, trigger engine, controller, local and remote sources, popup, insert. The sending loop goes to the server: serializer, message API, mention extraction, permission checks, notifications, and back to every client as a new message event that renders labels from the user store.

Data flow in three rows. Typing loop: the Editor sends the query an to the Controller, which sends GET q=an to the Search API; 8 people come back to the Controller, and rows go to the Editor's popup, where a pick inserts a token. Sending loop: on send, the Editor gives the doc to the Serializer, which sends POST with doc and text to the Message API; the Message API passes valid ids to the Notifier and broadcasts message.created through the Socket. Rendering: the Socket delivers the event to the Message list, which sends mention ids to the User store and gets current names back.Editorquery: anControllerlocal firstSearch APIchannel awarequeryGETtop 8rowson send: docSerializerdoc + markupMessage APIvalidates idsNotifierU123 onlyPOSTidsmessage.createdUser storeid to nameSocketto every clientMessage listrenders labelsids, names back
Only two things cross the network while typing: the query and the rows. Names never cross with the message; ids do, and every client looks names up from its own store.

Notifications and permissions belong to the server

The client's mention list is a request, not a command. When a message arrives, the server walks the document, collects mention ids, and checks each one: does the user exist in this workspace, can they see this channel, is the sender allowed to use @channel here? Only then does it notify. Anything the client sends, including crafted HTML pasted into the composer, passes through these checks.

  • People outside a private channel are not notified and are not given access by a mention. The response lists them, and the client shows "Ana isn't in #release. Invite her?" so the sender decides.
  • Broadcast mentions like @channel and @here may be limited by admins to some roles or to channels under a size. The server enforces it; the client only hides the option when it already knows the rule.
  • Edits: when a message is edited, notify only mentions that are new in this version. Removing a mention can't take back a notification already delivered.
  • Muted and do not disturb: the person's own settings decide how the notification is delivered; the composer doesn't need to know.

Check your understanding · Exercise 8

Which responsibilities belong on the server rather than in the mention input? Select all that apply.

Interface: the component API

A design system team will ship this to many product teams: chat, issue comments, document editors. The API has to cover @, # and / without a new component per trigger. The component API design lesson covers the general rules; here are the mention-specific choices.

ts
type Suggestion = { id: string; label: string; kind: MentionKind; meta?: string };

type TriggerConfig = {
  char: "@" | "#" | "/" | ":";
  maxWords?: number;            // spaces allowed in the query
  startOnly?: boolean;          // "/" only at the start
  source: (query: string, ctx: { signal: AbortSignal }) =>
    Suggestion[] | Promise<Suggestion[]>;
  insert: "token" | "text";     // hashtags insert text, people insert tokens
};

type MentionInputProps = {
  value: MessageDoc;            // controlled document, not a string
  onChange: (doc: MessageDoc) => void;
  triggers: TriggerConfig[];
  renderSuggestion?: (s: Suggestion, state: { active: boolean }) => ReactNode;
  renderMention?: (m: { id: string; label: string; kind: MentionKind }) => ReactNode;
  onMention?: (m: Suggestion, trigger: string) => void;
  backspace?: "delete" | "select-first" | "to-query";
  maxSuggestions?: number;      // default 8
  "aria-label": string;
};
Props for a trigger-driven mention input.
  • value is a document, not a string. A string would force every team to parse markup; the serializer gives them a string when they need one.
  • source gets an AbortSignal, so a team's fetch is cancelled when the query changes. Returning a plain array (not a promise) means local results render in the same frame.
  • insert per trigger decides token or text, which answers the hashtag question from chapter 2.
  • onMention fires when a token is inserted, so a team can, for example, prefetch that user's presence or log analytics. It is not where notifications happen.
  • renderSuggestion and renderMention let teams show avatars or channel icons. The component keeps the roles, ids and keyboard model, so custom rendering can't break accessibility.

The endpoints it talks to

http
GET /api/mentions/search?q=ana&channel=C456&limit=8
200 OK
Cache-Control: private, max-age=30
{ "results": [
  { "id": "U123", "kind": "user", "label": "Ana Rodriguez",
    "meta": "Design", "inChannel": true },
  { "id": "S9", "kind": "group", "label": "analytics-team", "members": 14 }
] }

POST /api/channels/C456/messages
{ "doc": { "type": "doc", "content": [ ... ] },
  "text": "thanks <@U123> for the fix", "clientId": "m-7f3a" }

201 Created
{ "id": "M991", "notified": ["U123"], "notInChannel": [] }
Search, then send. Search is per workspace and channel aware.

The send response tells the client what the server decided: who was notified and who couldn't be reached. That lets the composer show the "invite them?" prompt without guessing. The clientId makes retries safe: if the network drops after the server saved the message, a resend with the same id doesn't create a duplicate or notify Ana twice.

Check your understanding · Exercise 9

A product team wants # to insert plain hashtags in posts, while @ inserts people. Using the API above, what do they configure? Select all that apply.

Accessibility, performance and testing

A mention popup is a combobox in spirit: typing filters a list, arrows pick, Enter commits. The command palette guide covers that keyboard model and aria-activedescendant, with role="combobox" on its one-line search input. The twist here is that the text box is a large, multi-line editor, and that shapes which roles to use.

Decision Which role does the editor get while suggestions are open?

Switch the editor to role combobox

Set role="combobox" with aria-expanded, aria-controls and aria-activedescendant on the editor while the popup is open.

Pros

  • Follows the APG combobox pattern exactly; aria-expanded tells users the list opened.

Cons

  • Changing the role of a focused element mid-typing confuses some screen readers.
  • ARIA 1.2's combobox doesn't support aria-multiline, so the message box loses its multi-line meaning.

Stay a textbox, point into the list

Keep the editor as a multi-line textbox. Add aria-autocomplete="list", aria-haspopup="listbox", aria-controls pointing at the listbox, and aria-activedescendant set to the active option id, which tells the screen reader which row is active while focus stays in the editor.

Announce opening and counts through a polite live region: "5 people found. Use up and down arrows."

Pros

  • ARIA 1.2 lets a textbox carry aria-activedescendant, aria-autocomplete and aria-haspopup, so this is valid ARIA.
  • Focus and role never change while typing.

Cons

  • textbox has no aria-expanded, so the open state must be spoken by the live region.
  • Support for aria-activedescendant on editable content varies; test with real screen readers.

What we pick: Stay a textbox and point into the list, plus a live region. It keeps the message box stable for screen reader users, and the live region covers what aria-expanded would have said. Test it in NVDA with Firefox and Chrome, JAWS with Chrome, and VoiceOver on macOS and iOS, because this is exactly where support differs.

  • Each row is an option with a name that reads well: "Ana Rodriguez, Design, in channel". Avatars are decorative and hidden from assistive tech.
  • On insert, announce "Mentioned Ana Rodriguez". On delete, "Removed mention Ana Rodriguez". These are the only signs a blind user gets that a token appeared or vanished.
  • In sent messages, a mention is a real link whose text is "@Ana Rodriguez", so it reads naturally as part of the sentence. Don't give it an aria-label that differs from what sighted users see.
  • Mentions need more than color to stand out (WCAG 1.4.1): a background pill or weight, with text contrast of at least 4.5:1 on that background.
  • Respect prefers-reduced-motion for any popup animation. On touch screens make each row at least 44 px tall: WCAG 2.2 AA only asks for 24 px targets, but 44 px is the AAA target and Apple's guideline, and fingers miss small rows.

Performance budget

StepBudgetHow we stay inside it
Trigger detection per updateUnder 0.1 msScan at most 64 characters back from the caret.
Local filter of 500 membersUnder 1 msPre-lowercased, accent-folded names; prefix match on each word.
Caret measurementOnce per queryMeasure the trigger, not the moving caret; re-measure only on scroll or resize.
Popup render8 rowsNo virtualization needed; cap results instead of rendering 200 rows.
Key press to next paint (INP)Under 200 ms at the 75th percentileNever await the network in the key handler; remote results arrive later and append.
Member list loadOnce per channel visitFetched when the composer gets focus, cached for the session.

Accent folding deserves a word: users type "jose" and expect "José". Fold both sides with normalize("NFD") and remove combining marks before comparing, and compare with localeCompare or Intl.Collator when sorting names, as the tag input guide does for duplicates.

Testing what breaks

The pure parts get table tests: dozens of strings for findQuery (emails, URLs, brackets, two spaces, / mid-sentence) and for the serializer (escaping <, > and &, round trips from document to markup and back). End-to-end tests drive real typing, paste, undo and Backspace into tokens. IME and Android keyboards can't be fully simulated: keep a short manual checklist and run it on a real Android phone with Gboard and on an iPhone before each release.

Check your understanding · Exercise 10

A screen reader user types @an, arrows to the second row, and presses Enter. What should they hear along the way? Select all that apply.

Running it in the interview

Forty-five minutes is enough if you spend them on the parts that are special to mentions. A plan that works:

MinutesWhat to doWhat to draw or say
0 to 5Requirements: rich or plain text, triggers, names with spaces, renames, mobile.Say the five hard parts from chapter 2 out loud.
5 to 10Pick the editing surface.Textarea with overlay vs editor with mention nodes; pick one and say why.
10 to 18Trigger detection.Write findQuery rules: boundary, stop characters, spaces, dismissed state.
18 to 25Tokens and storage.Draw the document with a mention node, the <@U123> markup, and render-from-id.
25 to 32Architecture and data flow.Boxes for engine, controller, popup, store, serializer; the server deciding notifications.
32 to 40Deep dive the interviewer picks: caret positioning, IME, or accessibility.Mirror div or range rectangle; isComposing; textbox plus aria-activedescendant.
40 to 45API and wrap-up.The trigger config props and what you'd test first.

Follow-up questions to expect

Think first

Two people are both called "Ana Rodriguez". How does the user tell them apart, and how does the system?

Think first

The user is offline. What still works?

Think first

A channel has 80,000 members. Does the local-first plan still work?

If you run short on time, cut the API section, not the token model. An interviewer can forgive a missing prop list. Storing @Ana as text, or letting the client decide who gets notified, are the mistakes they listen for, and avoiding them is what separates levels.

Summary

  • A mention input is a trigger engine inside a text editor: detect a query, place a popup, search, and insert. @, #, / and : share it.
  • Pick the surface first: an editor with atom mention nodes for rich text, a textarea with a highlight overlay for plain text. Avoid raw contenteditable.
  • Detect queries from the text before the caret after every input, never from key events: word boundaries, stop characters, a word limit for names with spaces, and a dismissed state.
  • Anchor the popup to the trigger character: a range rectangle in editors, a mirror div in textareas, flipped and clamped against the visual viewport.
  • Show local context instantly, append server results without moving the active row, and abort stale requests.
  • Store tokens with ids, send a document plus <@U123> style markup, and render labels from current user data with the snapshot as fallback.
  • Tokens are atomic: one undoable insert, caret never inside, Backspace deletes the whole token, and any edit to a name in a textarea turns it back into text.
  • Let IME and Android keyboards work: ignore popup keys while isComposing or keyCode 229.
  • The server decides who is notified from validated ids, and reports who wasn't reachable so the client can offer an invite.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next