react ~6 KB 0 deps v0.1.0 β†— GitHub β†—

@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):

Props
TSX
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 #

KeyAction
@ or # (trigger char)Opens suggestion dropdown
ArrowDownMove selection down
ArrowUpMove selection up
EnterInsert selected suggestion
EscapeClose dropdown (insert nothing)
TabClose dropdown (insert nothing)
Space (allowSpaces=false)Close dropdown

API β€” MentionStyled props #

PropTypeDefaultDescription
valuestringβ€”Controlled textarea value
defaultValuestring""Uncontrolled initial value
onChange(value: string) => voidβ€”Called on every change with the full textarea string
triggersMentionTrigger[][{ char: "@" }]Array of trigger configurations
disabledbooleanfalseDisables the textarea
readOnlybooleanfalseMakes the textarea read-only
placeholderstringβ€”Textarea placeholder text
rowsnumber3Visible row count for the textarea
labelstringβ€”Visible field label
hintstringβ€”Hint text rendered below the textarea
errorstringβ€”Error message β€” also sets data-invalid and red border
size"sm" | "md" | "lg""md"Font and padding scale
classNamestringβ€”Extra class on root wrapper
styleCSSPropertiesβ€”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.
refRef<HTMLTextAreaElement>β€”Forwarded to the textarea element

API β€” MentionTrigger #

FieldTypeDefaultDescription
charstringrequiredTrigger character (e.g. "@" or "#")
loadSuggestions(query: string) => MentionSuggestion[] | Promise<MentionSuggestion[]>requiredReturns suggestions for the current query. May be async.
minCharsnumber0Minimum characters typed after the trigger before the dropdown opens
maxSuggestionsnumber8Maximum number of items shown in the dropdown
allowSpacesbooleanfalseWhether 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 #

FieldTypeDescription
idstringUnique identifier (used as React key)
labelstringDisplay text in the dropdown
valuestringText inserted into the textarea (defaults to label)
avatarstringOptional image URL shown as a circular avatar
descriptionstringOptional subtitle shown below the label

API β€” useMention options #

FieldTypeDescription
valuestringControlled textarea value
defaultValuestringUncontrolled initial value
onChange(value: string) => voidCalled on every change with the full textarea string
triggersMentionTrigger[]Array of trigger configurations
disabledbooleanDisables the textarea
readOnlybooleanMakes the textarea read-only
onMentionAdd(triggerChar: string, suggestion: MentionSuggestion) => voidCalled when a suggestion is selected and inserted. Not called on Escape or Tab.

API β€” useMention return #

FieldTypeDescription
textareaPropsobjectSpread onto <textarea>. Includes value, onChange, onKeyDown, role, aria-* props
dropdownPropsobjectSpread onto the dropdown list element. Sets id and role="listbox"
getItemProps(index: number) => objectReturns id, role="option", and aria-selected for each item
isOpenbooleanWhether the suggestion dropdown is open
isLoadingbooleanTrue while an async loadSuggestions Promise is pending
querystringCurrent query string (text after the trigger char)
triggerCharstringThe trigger character that opened the dropdown
suggestionsMentionSuggestion[]Current list of suggestions to render
activeSuggestionnumberIndex of the keyboard-focused suggestion
selectSuggestion(index: number) => voidInserts the suggestion at the given index into the text
close() => voidCloses the dropdown without inserting anything
valuestringCurrent 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;
}
Edit this page on GitHub