Adding @Mentions and Tagging to a Text Input in React
Adding @mentions to a text input sounds like a weekend feature. You watch for an @, pop a dropdown, insert a name. Then you ship it, and the bug reports arrive. The chip is editable, so people delete half of it and leave a dangling @joh. The async search races, so a slow request for "jo" lands after the fast one for "john" and overwrites it. Copy-paste turns three carefully-resolved mentions back into plain text. What looked like a weekend feature is actually a small state machine living inside a contentEditable, and the details are where most implementations quietly fall apart.
This article walks through what it actually takes to build mention autocomplete and #tagging in a React text input. We cover the trigger detection, the cancellable search, the immutable chips, and the keyboard handling, plus where a purpose-built tool like Prompt Area saves you from reimplementing all of it.
The four hard parts of a tagging input
Almost every tagging input in React, regardless of framework, has to solve the same four problems. They are deceptively independent: you can get three right and still have an input that feels broken.
1. Trigger detection that knows where the caret is
A trigger is not "the string contains an @." It is "the user just typed @ at a word boundary, and the caret is still inside the token they're building." You need to read the text immediately to the left of the caret, confirm the @ isn't part of an email address or a word, and extract the partial query that follows it. As the user types more characters, that query grows; as they hit space or move the caret away, the trigger closes. In a plain <textarea> you can do this with selectionStart and a regex. In a contentEditable you're working with DOM ranges and text nodes, which is considerably more fiddly.
2. Async search with cancellation
The moment your suggestions come from a server, you have a race condition. Users type faster than the network responds, and responses do not arrive in order. The classic symptom: you type "al", then "ali", the "ali" request resolves first, then the stale "al" request resolves and clobbers the list with the wrong results. The fix is to make each search cancellable and ignore anything that resolves after a newer query started. The modern idiom for this is an AbortSignal.
async function searchUsers(query, { signal }) { const res = await fetch(`/api/users?q=${encodeURIComponent(query)}`, { signal }); if (!res.ok) return []; return res.json(); }
When a new keystroke supersedes the in-flight request, you call abort(), the fetch rejects with an AbortError, and the stale result never touches your UI. Debouncing helps reduce request volume, but debouncing alone does not fix ordering. You need real cancellation underneath it.
3. Immutable chip pills
Once a mention resolves, it should stop behaving like text. A resolved @John Doe is a single atomic object: clicking into the middle of it shouldn't split it, and backspace should remove the whole thing, not one character of the display name. Rendering these as immutable chip pills (non-editable inline elements with their own data attached) is what makes the input feel like Linear or Slack rather than a search box that happens to autocomplete.
4. Keyboard navigation and backspace-to-revert
The dropdown has to be drivable entirely from the keyboard: arrow keys to move the highlight, Enter or Tab to select, Escape to dismiss. And there's a subtle interaction people love once it exists and never notice until it's missing: backspace-to-revert. Press backspace right after a chip, and instead of deleting the whole pill silently, the chip dissolves back into its trigger text (@John) so you can edit the query and re-search. It turns a destructive action into a forgiving one.
The Segment data model
The cleanest way to keep all of this sane is to stop thinking of the value as a string and start thinking of it as a list of segments. This is the model Prompt Area uses, and it's worth borrowing even if you build your own input. The value is a Segment[] array, where each segment is either text or a chip:
type TextSegment = { type: 'text'; text: string }; type ChipSegment = { type: 'chip'; trigger: string; // '@', '#', '/' value: string; // stable id, e.g. user id displayText: string; // 'John Doe' data?: unknown; // anything you attach: avatar, email... autoResolved?: boolean; };
This single decision pays for itself everywhere. A ChipSegment is immutable by construction, with no "half a chip" state to represent. The value is a stable id, so when you submit you send user_123, not the brittle display string. The optional data lets you carry an avatar URL or email along for the ride. And because the model is explicit, serialization is trivial: helpers like segmentsToPlainText and getChipsByTrigger let you flatten to a prompt string for display, or pull out structured mentions to send to your backend, with no regex parsing of the rendered text.
Defining a trigger with Prompt Area
Here's where the weekend-feature fantasy becomes real. Prompt Area is a contentEditable input built specifically for prompt-style and chat-composer boxes, and triggers are a first-class concept. Instead of wiring caret math and abort controllers by hand, you declare a trigger and hand it an onSearch function. The component owns the caret detection, the dropdown, the keyboard nav, and the chip lifecycle; you own the data.
import { PromptArea, usePromptAreaState } from 'prompt-area'; const mentions = { char: '@', position: 'any', mode: 'dropdown', async onSearch(query, { signal }) { const users = await searchUsers(query, { signal }); return users.map((u) => ({ value: u.id, label: u.name, data: { avatar: u.avatarUrl, email: u.email }, })); }, }; function Composer() { const state = usePromptAreaState(); return <PromptArea {...state.bind} triggers={[mentions]} />; }
Note the { signal } in onSearch(query, { signal }). That AbortSignal is handed to you precisely so you can pass it straight into fetch and let stale requests cancel themselves. The race condition from part two is handled for you, as long as you forward the signal. Each result you return is a suggestion with a value and a label; when the user picks one it becomes an immutable ChipSegment pill carrying your data.
For the common cases there are presets so you don't even write the boilerplate. mentionTrigger wires up @ mentions, hashtagTrigger wires up # tags, and callbackTrigger lets a trigger fire a callback instead of opening a dropdown. You typically hand them your onSearch and go:
import { mentionTrigger, hashtagTrigger } from 'prompt-area'; <PromptArea {...state.bind} triggers={[ mentionTrigger({ onSearch: searchUsers }), hashtagTrigger({ onSearch: searchTags }), ]} />
Why copy-paste is the real test
If you want to know whether a tagging input was built properly, copy a message with two mentions in it and paste it somewhere else, then paste it back. A naïve implementation loses the chips on the way out and re-inserts them as dead text on the way in. Because Prompt Area's value is a Segment[], copy and paste can preserve the chip data internally so the pills survive a round-trip, and when you paste text from an external source, the triggers can be auto-resolved back into chips (that's the autoResolved flag on the segment). This is the kind of behavior nobody writes a ticket asking for, but everybody notices when it's missing.
The trap with user mentions isn't the happy path. It's the dozen edge cases around it: caret position, request ordering, chip atomicity, paste fidelity. A string-based input forces you to defend all of them by hand; a segment-based one makes most of them impossible by construction.
Build vs. adopt
You can absolutely build @mentions input yourself, and for a learning exercise it's worth doing once. But weigh it honestly. The trigger detection alone is a real chunk of contentEditable work, the cancellation logic is easy to get subtly wrong, and the chip immutability tends to fight you for weeks as new edge cases surface in production. If your mentions live inside a chat or prompt composer, which is where most of them do, adopting an input designed for exactly that shape is the pragmatic call.
What makes Prompt Area a good fit here specifically is that it isn't a general document editor bent into a chat box. It ships with zero extra editor dependencies (no ProseMirror, Slate, or Lexical), you can install it from npm with a self-contained stylesheet or copy the source in via the shadcn registry, and the surface area is small: one PromptArea component and one usePromptAreaState() hook. The mention, hashtag, and command behaviors you'd otherwise spend a sprint on are the things it was built to do.
Start with the Segment model in your head, get cancellation right early, and treat resolved mentions as immutable from the first commit. Whether you hand-roll it or reach for Prompt Area, those three decisions are what separate a tagging input that demos well from one that survives real users.


















