react-mentions gives you a textarea that supports @user, #tag, or any trigger-based mention, with suggestion popups and highlighted inline display. It's used in production at Wix, Highlight, and a dozen others.
Why I starred it
The UX pattern looks deceptively simple — type @, get a dropdown, pick a name, done. The engineering problem underneath is not simple at all. You're dealing with a <textarea> that has no DOM structure inside its content, trying to render visual highlights over user-typed text, and tracking cursor position through markup transformations. Most attempts either use a contenteditable (hello, cursor management hell) or a custom editor primitive. react-mentions does neither — it layers a hidden <div> over the real textarea and syncs them character by character.
How it works
The core trick is in src/Highlighter.js. While the actual input is a regular <textarea>, a companion <div> is rendered with identical styling and positioned absolutely behind it. This div renders the plain text with <mark>-style highlights for each mention. Since both elements use the same font, padding, and line-height, they appear visually merged — the textarea handles input, the div handles display.
The glue between them is iterateMentionsMarkup in src/utils/iterateMentionsMarkup.js. The value stored internally isn't plain text — it's a markup string like @[Florian](__id__). This function walks that string, calling separate callbacks for plain text segments and for mention tokens:
const iterateMentionsMarkup = (value, config, markupIteratee, textIteratee = emptyFn) => {
const regex = combineRegExps(config.map(c => c.regex))
// ...
while ((match = regex.exec(value)) !== null) {
const offset = captureGroupOffsets.find(o => !!match[o])
const mentionChildIndex = captureGroupOffsets.indexOf(offset)
const { markup, displayTransform } = config[mentionChildIndex]
// extract id and display, call callbacks
}
}
Every render cycle — on keypress, on selection change — this iterator runs over the entire value string. Not the cheapest approach for large inputs with many mentions, but it's predictable and easy to reason about.
The harder problem is keeping cursor position correct. When you have @[Alice](alice-id) and @[Bob](bob-id), the stored value is much longer than what the user sees. Translating between cursor position in plain text and cursor position in markup is what mapPlainTextIndex in src/utils/mapPlainTextIndex.js handles. It uses iterateMentionsMarkup and walks forward until it finds the plain-text index, then returns the corresponding markup index. The inMarkupCorrection parameter ('START', 'END', or 'NULL') controls what happens when the cursor lands inside a mention token — you can either snap to the beginning, the end, or return null to block it.
Trigger detection in src/MentionsInput.js is built around makeTriggerRegex:
export const makeTriggerRegex = function(trigger, options = {}) {
if (trigger instanceof RegExp) {
return trigger
} else {
const escapedTriggerChar = escapeRegex(trigger)
return new RegExp(
`(?:^|\\s)(${escapedTriggerChar}([^${
allowSpaceInQuery ? '' : '\\s'
}${escapedTriggerChar}]*))$`
)
}
}
Pass a string like '@' and you get a regex that matches @query at the end of the current input segment. Pass your own RegExp and you bypass the builder entirely — useful for custom trigger patterns.
Multiple <Mention> children, each with different trigger characters, get merged into a single combined regex via combineRegExps. The component doesn't loop over data sources — it fires all triggers simultaneously against the current value and routes results back to the right source.
Using it
import { MentionsInput, Mention } from 'react-mentions'
function CommentBox({ users, tags }) {
const [value, setValue] = React.useState('')
return (
<MentionsInput
value={value}
onChange={(e, newValue)=> setValue(newValue)}
allowSuggestionsAboveCursor
>
<Mention
trigger="@"
data={users}
markup="@[__display__](__id__)"
appendSpaceOnAdd
/>
<Mention
trigger="#"
data={(search, callback)=> {
fetchTags(search).then(callback)
}}
/>
</MentionsInput>
)
}
The value stored in state is the markup string (@[Alice](alice-id)), not plain text. For display, the component handles transformation internally. For sending to a server, you probably want getPlainText from the utils index to strip it back down — or keep the markup if your backend understands it.
Styling is flexible: className, CSS modules, or inline styles are all supported. The suggestion list positioning respects allowSuggestionsAboveCursor for tight viewports.
Rough edges
Maintenance is the elephant in the room. The last substantive commit was adding inputComponent support in 2023. The recent commit history is almost entirely Dependabot bumps and a single changesets bot release. The repo has 2,600+ stars and a healthy issue tracker full of open reports — several about IE compatibility code that's still in the codebase via isIE.js, which nobody needs anymore.
The component is a class component (MentionsInput extends React.Component) while Highlighter has been converted to hooks. Mixing paradigms isn't wrong, but it signals the codebase isn't being actively modernized.
The documentation for the markup template syntax is thin. The default is @[__display__](__id__) but there's no clear explanation of how the __placeholder__ syntax maps to capture groups, or what custom markup patterns are valid. You have to trace through markupToRegex.js to understand the constraints.
Testing uses @testing-library/user-event as recommended in the README, but the test coverage in src/*.spec.js is fairly shallow — mostly happy-path rendering. The utility tests in src/utils/*.spec.js are more thorough.
Bottom line
If you need @mentions in a React textarea and don't want to reach for a full editor like Slate or TipTap, this is the right tool. The architecture is solid and the markup format is clean. Just go in knowing it's in maintenance mode — don't expect fast issue responses or new features.
