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.
| Question | Answer we assume | What 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,
EnterorTabinserts,Escapecloses, 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
@channeland@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.
- 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.comor a URL. - 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 atextarea. - 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. - 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.
- Keyboards lie. On Android, many virtual keyboards report
keyCode229 for every key while a word is being composed, and autocorrect can replace a whole word at once. Code that listens for akeydownof@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.
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?
Not necessarily. A Slack channel is a real object with an id that can be renamed, so it should be a token like a person: #design must keep working after the channel becomes #design-team.
A hashtag on a social site is just a word. There is no object behind it to rename, and the text itself is the identity. Storing it as text is correct, and the trigger only helps people type popular tags.
So the question to ask per trigger is: does the result point to something with an id that can change? If yes, store a token. If no, insert text.
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?
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.
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.
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;
}
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.comthe@follows a letter, so it's skipped. Inx.com/@anait 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.
Query open. Trigger @ at index 7, query "an", 3 matches: Ana Rodriguez, Ana Silva, Anand Kumar. The popup anchors at index 7.
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.
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?
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.
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 };
}
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.
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?
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
| Signal | Why it matters | Where it comes from |
|---|---|---|
| Took part in this thread | Replies usually mention people already talking. | The thread's messages, already loaded. |
| Member of this channel | Mentioning a member never needs an invite. | Channel member list, cached. |
| You talked recently | People 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 away | Slightly 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.
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);
}
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.
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.
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" },
] }],
};
Decision How is the message sent to the server and stored?
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.
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
copyevent.text/plaingets readable text: "thanks @Ana Rodriguez for the fix".text/htmlgets spans with data attributes, likespan 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 adata-pm-sliceattribute. - Why not a custom type like
application/x-mentions+json?setDataaccepts one, but Safari only shows custom types to pages from the same origin, and other apps ignore them.text/htmlsurvives 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.
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
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.
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?
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
Enterconfirms the converted text. If the mention popup grabsEnterduring composition, it inserts a person instead of confirming the word. Checkevent.isComposingand alsokeyCode229, because Safari has long fired that confirmingkeydownaftercompositionend, withisComposingalready false. Run trigger detection again oncompositionend. - Android keyboards. Many send
keydownwithkeyCode229 andkeyset toUnidentifiedfor every letter while a word is in progress. That is why detection reads the text afterinput, 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
beforeinputevent with aninputTypelikeinsertReplacementText. Since detection is text based, the query updates correctly either way. Turn off theautocapitalizeandautocorrectattributes only if testing shows they ruin queries; users still want spelling help in the rest of the message.
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();
}
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.
| Part | Job | Why 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 engine | Runs findQuery on each update and holds the idle, open, dismissed state. | Pure logic, unit tested with strings. Shared by @, # and /. |
| Caret anchor | Measures the trigger character's box and feeds positioning. | Different code for textarea and editor, same output shape. |
| Suggestion controller | Calls each trigger's sources, merges, ranks, aborts stale requests. | Owns all async work, so the popup is a plain view. |
| Suggestion popup | Renders rows, groups and warnings; reports clicks and hover. | A dumb list that teams can restyle with renderSuggestion. |
| User store | Maps ids to current names and avatars; batches lookups. | Shared with the message list, so renames show everywhere at once. |
| Serializer | Turns the document into JSON, markup like <@U123>, and plain text. | Sending, copying and previews all need the same conversion. |
| Announcer | A polite live region for result counts and inserted or removed mentions. | One place for spoken feedback, so messages don't overlap. |
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.
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
@channeland@heremay 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.
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;
};
valueis a document, not a string. A string would force every team to parse markup; the serializer gives them a string when they need one.sourcegets anAbortSignal, 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.insertper trigger decides token or text, which answers the hashtag question from chapter 2.onMentionfires 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.renderSuggestionandrenderMentionlet 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
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": [] }
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?
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
optionwith 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-labelthat 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-motionfor 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
| Step | Budget | How we stay inside it |
|---|---|---|
| Trigger detection per update | Under 0.1 ms | Scan at most 64 characters back from the caret. |
| Local filter of 500 members | Under 1 ms | Pre-lowercased, accent-folded names; prefix match on each word. |
| Caret measurement | Once per query | Measure the trigger, not the moving caret; re-measure only on scroll or resize. |
| Popup render | 8 rows | No virtualization needed; cap results instead of rendering 200 rows. |
| Key press to next paint (INP) | Under 200 ms at the 75th percentile | Never await the network in the key handler; remote results arrive later and append. |
| Member list load | Once per channel visit | Fetched 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:
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Requirements: rich or plain text, triggers, names with spaces, renames, mobile. | Say the five hard parts from chapter 2 out loud. |
| 5 to 10 | Pick the editing surface. | Textarea with overlay vs editor with mention nodes; pick one and say why. |
| 10 to 18 | Trigger detection. | Write findQuery rules: boundary, stop characters, spaces, dismissed state. |
| 18 to 25 | Tokens and storage. | Draw the document with a mention node, the <@U123> markup, and render-from-id. |
| 25 to 32 | Architecture and data flow. | Boxes for engine, controller, popup, store, serializer; the server deciding notifications. |
| 32 to 40 | Deep dive the interviewer picks: caret positioning, IME, or accessibility. | Mirror div or range rectangle; isComposing; textbox plus aria-activedescendant. |
| 40 to 45 | API 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?
The system never has a problem: tokens hold ids, so each mention points to exactly one account.
The user needs help in the popup: show a second line such as team, title, or email domain, and an avatar. Ranking also helps, because the Ana in this channel ranks first.
In sent messages, hovering or focusing the mention shows a profile card, so readers can check who was meant.
Think first
The user is offline. What still works?
Detection, local suggestions from the cached member list, insertion and editing all work, because none of them need the network.
Workspace-wide search shows a quiet "Can't search everyone offline" row instead of a spinner.
Sending queues the message with its clientId. The server checks mentions when it finally arrives, so a person removed from the channel in the meantime isn't notified.
Think first
A channel has 80,000 members. Does the local-first plan still work?
Not as a full member list: 80,000 names with avatars is several MB and slow to filter on a phone.
Keep a small local set instead: thread participants, recent contacts, and the most active members, a few hundred people. Everything else comes from server search.
Also show the count on @channel and ask for confirmation, since one keystroke would notify 80,000 people.
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
isComposingorkeyCode229. - 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.
References
- WAI-ARIA Authoring Practices: Combobox pattern The keyboard and
aria-activedescendantmodel behind the suggestion list. - WAI-ARIA 1.2 specification Check which states the
textboxandcomboboxroles support. - MDN: Range.getBoundingClientRect() Measuring a character's box in editable content.
- MDN: KeyboardEvent.isComposing Telling IME composition keys apart from real commands.
- MDN: VisualViewport Finding the space the on-screen keyboard leaves.
- MDN: CSS anchor positioning What anchors can and can't be.
- textarea-caret-position The classic mirror-div implementation.
- Tiptap: Mention extension Mention nodes, multiple triggers, and Backspace behaviour.
- Tiptap: Suggestion utility Trigger options such as
allowedPrefixes,allowSpacesandstartOfLine, and theclientRectused for positioning. - react-mentions on npm The textarea plus highlighter approach, with the default
@[__display__](__id__)markup. - MDN: InputEvent.inputType Values like
insertReplacementTextthat autocorrect and paste produce. - WebKit: Clipboard API improvements Why custom clipboard types are limited to the same origin in Safari.
- Slack: Formatting message text How Slack writes user, channel and special mentions in markup, and what must be escaped.
- Lexical: Nodes Text node modes, including
token.
Concepts behind it
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.