@mshafiqyajid/react-mention
Headless mention hook and styled autocomplete textarea. Detects trigger characters (@, #, or custom), positions a suggestion dropdown at the caret, supports async suggestion loading, avatars, descriptions, multiple triggers, full keyboard navigation, ARIA combobox/listbox, portal to document.body. Zero dependencies.
Playground #
Custom renderSuggestion (card style) with onMentionAdd callback and async loading (600 ms delay):
import { MentionStyled } from "@mshafiqyajid/react-mention/styled";
import "@mshafiqyajid/react-mention/styles.css";
<MentionStyled />Install #
npm install @mshafiqyajid/react-mention Quick start #
import { MentionStyled } from "@mshafiqyajid/react-mention/styled";
import "@mshafiqyajid/react-mention/styles.css";
const users = [
{ id: "1", label: "Alice", description: "Product" },
{ id: "2", label: "Bob", description: "Engineering" },
];
<MentionStyled
label="Message"
placeholder="Type @ to mention someoneβ¦"
triggers={[{
char: "@",
loadSuggestions: (query) =>
users.filter(u =>
u.label.toLowerCase().startsWith(query.toLowerCase())
),
}]}
/> Multiple triggers #
Pass multiple trigger configs to support @ mentions and # hashtags simultaneously. Each trigger has its own loadSuggestions function, making it easy to source data from different endpoints.
<MentionStyled
triggers={[
{
char: "@",
loadSuggestions: (q) =>
users.filter(u => u.label.toLowerCase().startsWith(q.toLowerCase())),
maxSuggestions: 8,
},
{
char: "#",
loadSuggestions: (q) =>
tags.filter(t => t.label.toLowerCase().startsWith(q.toLowerCase())),
maxSuggestions: 6,
},
]}
/> Async suggestions #
loadSuggestions can return a Promise. The dropdown stays open and populates when the promise resolves. Stale results from cancelled queries are automatically discarded.
<MentionStyled
triggers={[{
char: "@",
loadSuggestions: async (query) => {
const res = await fetch(`/api/users?q=${query}`);
const data = await res.json();
return data.users; // MentionSuggestion[]
},
minChars: 1,
maxSuggestions: 8,
}]}
/> Loading state #
When loadSuggestions returns a Promise, the styled component automatically shows a CSS spinner row inside the dropdown while the promise is pending. The headless hook exposes isLoading: boolean so you can render your own indicator when building a custom UI.
// Headless β use isLoading to show a custom indicator
const { isLoading, suggestions, isOpen } = useMention({ triggers });
{isOpen && isLoading && <p>Loadingβ¦</p>}
{isOpen && !isLoading && suggestions.map((s) => <div key={s.id}>{s.label}</div>)} Custom renderer #
Supply renderSuggestion on any trigger to fully control how each suggestion item is rendered. The function receives the MentionSuggestion and a boolean isActive flag (keyboard-focused). The default avatar + label + description layout is used when renderSuggestion is omitted.
<MentionStyled
triggers={[{
char: "@",
loadSuggestions: (q) => users.filter(u => u.label.startsWith(q)),
renderSuggestion: (suggestion, isActive) => (
<span style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<span
style={{
width: 32, height: 32, borderRadius: 6,
background: isActive ? "#eef2ff" : "#f4f4f5",
display: "flex", alignItems: "center", justifyContent: "center",
fontWeight: 700, fontSize: "0.75rem",
}}
>
{suggestion.label.slice(0, 2).toUpperCase()}
</span>
<span>
<strong>{suggestion.label}</strong>
{suggestion.description && <small style={{ display: "block" }}>{suggestion.description}</small>}
</span>
</span>
),
}]}
/> onMentionAdd callback #
Pass onMentionAdd to be notified whenever a suggestion is selected and inserted. Useful for analytics, side-effects, or building a list of mentions in the document.
<MentionStyled
onMentionAdd={(triggerChar, suggestion) => {
console.log(`Mentioned ${triggerChar}${suggestion.label}`);
// triggerChar β "@" | "#" | any trigger char
// suggestion β MentionSuggestion
}}
triggers={[{
char: "@",
loadSuggestions: (q) => users.filter(u => u.label.startsWith(q)),
}]}
/> The callback fires only on selection β it is not called when the dropdown is dismissed with Escape or Tab.
Headless #
Use useMention directly to wire your own UI. Spread textareaProps onto a <textarea>, render the dropdown yourself using dropdownProps, getItemProps, and selectSuggestion.
import { useMention } from "@mshafiqyajid/react-mention";
const {
textareaProps,
dropdownProps,
getItemProps,
isOpen,
suggestions,
activeSuggestion,
selectSuggestion,
close,
} = useMention({
triggers: [{
char: "@",
loadSuggestions: (q) => users.filter(u => u.label.includes(q)),
}],
onChange: (value) => console.log(value),
});
return (
<div style={{ position: "relative" }}>
<textarea {...textareaProps} />
{isOpen && (
<ul {...dropdownProps}>
{suggestions.map((s, i) => (
<li
key={s.id}
{...getItemProps(i)}
onMouseDown={(e) => { e.preventDefault(); selectSuggestion(i); }}
style={{ background: i === activeSuggestion ? "#eef2ff" : undefined }}
>
{s.label}
</li>
))}
</ul>
)}
</div>
); Keyboard #
| Key | Action |
|---|---|
| @ or # (trigger char) | Opens suggestion dropdown |
| ArrowDown | Move selection down |
| ArrowUp | Move selection up |
| Enter | Insert selected suggestion |
| Escape | Close dropdown (insert nothing) |
| Tab | Close dropdown (insert nothing) |
| Space (allowSpaces=false) | Close dropdown |
API β MentionStyled props #
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | β | Controlled textarea value |
| defaultValue | string | "" | Uncontrolled initial value |
| onChange | (value: string) => void | β | Called on every change with the full textarea string |
| triggers | MentionTrigger[] | [{ char: "@" }] | Array of trigger configurations |
| disabled | boolean | false | Disables the textarea |
| readOnly | boolean | false | Makes the textarea read-only |
| placeholder | string | β | Textarea placeholder text |
| rows | number | 3 | Visible row count for the textarea |
| label | string | β | Visible field label |
| hint | string | β | Hint text rendered below the textarea |
| error | string | β | Error message β also sets data-invalid and red border |
| size | "sm" | "md" | "lg" | "md" | Font and padding scale |
| className | string | β | Extra class on root wrapper |
| style | CSSProperties | β | Inline style on root wrapper |
| onMentionAdd | (triggerChar: string, suggestion: MentionSuggestion) => void | β | Called when a suggestion is selected and inserted. Not called on Escape or Tab. |
| ref | Ref<HTMLTextAreaElement> | β | Forwarded to the textarea element |
API β MentionTrigger #
| Field | Type | Default | Description |
|---|---|---|---|
| char | string | required | Trigger character (e.g. "@" or "#") |
| loadSuggestions | (query: string) => MentionSuggestion[] | Promise<MentionSuggestion[]> | required | Returns suggestions for the current query. May be async. |
| minChars | number | 0 | Minimum characters typed after the trigger before the dropdown opens |
| maxSuggestions | number | 8 | Maximum number of items shown in the dropdown |
| allowSpaces | boolean | false | Whether the query can contain spaces (e.g. for full-name search) |
| renderSuggestion | (suggestion: MentionSuggestion, isActive: boolean) => ReactNode | β | Custom renderer for each suggestion row. When omitted the default avatar + label + description layout is used. |
API β MentionSuggestion #
| Field | Type | Description |
|---|---|---|
| id | string | Unique identifier (used as React key) |
| label | string | Display text in the dropdown |
| value | string | Text inserted into the textarea (defaults to label) |
| avatar | string | Optional image URL shown as a circular avatar |
| description | string | Optional subtitle shown below the label |
API β useMention options #
| Field | Type | Description |
|---|---|---|
| value | string | Controlled textarea value |
| defaultValue | string | Uncontrolled initial value |
| onChange | (value: string) => void | Called on every change with the full textarea string |
| triggers | MentionTrigger[] | Array of trigger configurations |
| disabled | boolean | Disables the textarea |
| readOnly | boolean | Makes the textarea read-only |
| onMentionAdd | (triggerChar: string, suggestion: MentionSuggestion) => void | Called when a suggestion is selected and inserted. Not called on Escape or Tab. |
API β useMention return #
| Field | Type | Description |
|---|---|---|
| textareaProps | object | Spread onto <textarea>. Includes value, onChange, onKeyDown, role, aria-* props |
| dropdownProps | object | Spread onto the dropdown list element. Sets id and role="listbox" |
| getItemProps | (index: number) => object | Returns id, role="option", and aria-selected for each item |
| isOpen | boolean | Whether the suggestion dropdown is open |
| isLoading | boolean | True while an async loadSuggestions Promise is pending |
| query | string | Current query string (text after the trigger char) |
| triggerChar | string | The trigger character that opened the dropdown |
| suggestions | MentionSuggestion[] | Current list of suggestions to render |
| activeSuggestion | number | Index of the keyboard-focused suggestion |
| selectSuggestion | (index: number) => void | Inserts the suggestion at the given index into the text |
| close | () => void | Closes the dropdown without inserting anything |
| value | string | Current textarea value (controlled or internal) |
CSS variables #
:root {
--rmen-font-size-sm: 0.8125rem;
--rmen-font-size-md: 0.875rem;
--rmen-font-size-lg: 1rem;
--rmen-radius-sm: 6px;
--rmen-radius-md: 8px;
--rmen-radius-lg: 10px;
--rmen-padding-x-sm: 10px;
--rmen-padding-x-md: 12px;
--rmen-padding-x-lg: 14px;
--rmen-duration: 150ms;
--rmen-ease: cubic-bezier(0.4, 0, 0.2, 1);
/* Colors */
--rmen-bg: #ffffff;
--rmen-bg-readonly: #f4f4f5;
--rmen-fg: #18181b;
--rmen-placeholder: #a1a1aa;
--rmen-border: #d4d4d8;
--rmen-border-hover: #a1a1aa;
--rmen-border-focus: #6366f1;
--rmen-shadow-focus: rgba(99, 102, 241, 0.18);
--rmen-label-fg: #374151;
--rmen-hint-fg: #6b7280;
--rmen-error-fg: #dc2626;
--rmen-error-border: #dc2626;
--rmen-error-shadow: rgba(220, 38, 38, 0.18);
/* Dropdown */
--rmen-dropdown-bg: #ffffff;
--rmen-dropdown-border: #e4e4e7;
--rmen-dropdown-shadow: 0 4px 20px rgba(0, 0, 0, 0.10);
--rmen-dropdown-radius: 10px;
--rmen-item-hover-bg: #f4f4f5;
--rmen-item-active-bg: #eef2ff;
--rmen-item-active-fg: #4338ca;
--rmen-avatar-size: 1.75rem;
}