Skip to content

canta2899/mentionize

Repository files navigation

License

Mentionize Logo

Mentionize

A React library for building mention inputs with support for multiple triggers, async search, and full customization. It provides a transparent textarea overlaid on a highlighted div to display mentions, and a dropdown for suggestions. With zero dependencies other than React.

Install

npm install react
npm install react-dom

npm install mentionize

Quick Start

import { useState } from "react";
import { MentionInput } from "mentionize";
import type { MentionTrigger } from "mentionize";

const users = [
  { id: "1", name: "Alice" },
  { id: "2", name: "Bob" },
];

const userTrigger: MentionTrigger<{ id: string; name: string }> = {
  trigger: "@",
  displayText: (user) => user.name,
  serialize: (user) => `@[${user.name}](user:${user.id})`,
  pattern: /@\[([^\]]+)\]\(user:([^)]+)\)/g,
  parseMatch: (match) => ({ displayText: match[1]!, key: match[2]! }),
  options: users,
};

function App() {
  const [value, setValue] = useState("");

  return (
    <MentionInput
      triggers={[userTrigger]}
      value={value}
      onChange={setValue}
      placeholder="Type @ to mention someone..."
    />
  );
}

The value passed to onChange is the serialized form (e.g. Hello @[Alice](user:1)). The component handles converting between the serialized and visible representations automatically.

API

MentionTrigger<T>

Defines how a trigger character activates suggestions and how mentions are serialized/parsed.

Property Type Description
trigger string Character(s) that activate the trigger (e.g. "@", "#")
displayText (item: T) => string Converts an item to its visible text
serialize (item: T) => string Converts an item to its serialized form in the raw value
pattern RegExp Regex to detect serialized mentions (must use global flag)
parseMatch (match: RegExpExecArray) => { displayText: string; key: string; item?: T } Parses a regex match back into display text and key. Optionally returns item to seed the engine cache.
options? T[] Static options array (client-side filtering)
onSearch? (query: string, page: number) => Promise<{ items: T[]; hasMore: boolean }> Async search with pagination
renderOption? (item: T, highlighted: boolean) => ReactNode Custom option rendering
optionClassName? string | ((item: T) => string) CSS class for dropdown options, or a function for conditional styling per item
renderMention? (displayText: string, item?: unknown) => ReactNode Custom mention highlight rendering
mentionClassName? string | ((mention: MentionItemData) => string) CSS class for highlighted mentions, or a function for conditional styling
onSelect? (item: T) => Promise<string | null> | string | null Action trigger: runs instead of inserting a mention. Returns text to insert or null to cancel.

MentionInputProps

Property Type Description
triggers MentionTrigger<any>[] Array of trigger configurations
value? string Controlled raw/serialized value
defaultValue? string Initial raw value (uncontrolled mode)
onChange? (raw: string) => void Called when the raw value changes
onMentionsChange? (mentions: ActiveMention[]) => void Called when active mentions change
placeholder? string Textarea placeholder
disabled? boolean Disable the input
rows? number Textarea rows (default: 4)
className? string Container className
inputClassName? string Textarea className
highlighterClassName? string Highlighter overlay className
dropdownClassName? string Dropdown className
dropdownWidth? number Dropdown width in pixels (default: 250)
loadingContent? ReactNode Content shown while loading async results (default: "Loading...")
renderDropdown? (props: DropdownRenderProps) => ReactNode Full custom dropdown rendering
dropdownPositionStrategy? "fixed" | "absolute" Positioning strategy for the dropdown (default: "fixed"). Use "absolute" inside CSS-transformed ancestors such as modals — see Modals & CSS Transforms.
aria-label? string Accessible label for the textarea
aria-describedby? string ID of an element describing the textarea

Multiple Triggers

Pass multiple trigger configs to support different mention types:

const userTrigger: MentionTrigger<User> = { trigger: "@", /* ... */ };
const tagTrigger: MentionTrigger<Tag> = { trigger: "#", /* ... */ };

<MentionInput triggers={[userTrigger, tagTrigger]} />

Async Search with Pagination

Use onSearch instead of options for server-side search. The dropdown automatically loads more results when scrolled to the bottom.

const trigger: MentionTrigger<User> = {
  trigger: "@",
  displayText: (user) => user.name,
  serialize: (user) => `@[${user.name}](user:${user.id})`,
  pattern: /@\[([^\]]+)\]\(user:([^)]+)\)/g,
  parseMatch: (match) => ({ displayText: match[1]!, key: match[2]! }),
  onSearch: async (query, page) => {
    const res = await fetch(`/api/users?q=${query}&page=${page}`);
    return res.json(); // { items: User[], hasMore: boolean }
  },
};

Modals & CSS Transforms

Browsers create a new containing block for position: fixed elements when any ancestor has a CSS transform applied. This is a known browser behaviour that affects many libraries — the canonical example is a modal centred with transform: translate(-50%, -50%), which causes any position: fixed child (including the suggestion dropdown) to be positioned relative to the modal rather than the viewport, placing it far off-screen.

Use dropdownPositionStrategy="absolute" to switch the dropdown to position: absolute, anchoring it to the position: relative container that MentionInput already renders internally:

<Dialog>
  <DialogContent> {/* has transform: translate(-50%, -50%) */}
    <MentionInput
      dropdownPositionStrategy="absolute"
      triggers={[userTrigger]}
      value={value}
      onChange={setValue}
    />
  </DialogContent>
</Dialog>

The default is "fixed", so all existing usage outside of transformed ancestors is unaffected.

Cache Seeding via parseMatch

By default the engine only recognizes mentions whose items are already cached (from options, onSearch results, or previous selections). When a mention is injected externally — for example by a / command picker or when loading initial content containing mentions for items that haven't been searched yet — the cache may not contain the underlying item, so the mention won't be highlighted or serialized.

To solve this, parseMatch can optionally return an item field. When present, the engine seeds its internal cache with that item during raw-to-visible parsing, making the mention immediately detectable:

const modelTrigger: MentionTrigger<Model> = {
  trigger: "@",
  displayText: (model) => model.label,
  serialize: (model) => `@[${model.label}](model:${model.id})`,
  pattern: /@\[([^\]]+)\]\(model:([^)]+)\)/g,
  parseMatch: (match) => {
    const id = match[2]!;
    const label = match[1]!;
    // Look up the item from your own data source
    const cached = myModelCache.get(id);
    return {
      displayText: label,
      key: id,
      item: cached, // if defined, seeds the engine cache
    };
  },
  onSearch: async (query, page) => {
    const res = await fetch(`/api/models?q=${query}&page=${page}`);
    return res.json();
  },
};

This is useful when:

  • A command picker (e.g. / trigger with onSelect) injects a mention into the input
  • The input is initialized with raw text containing mentions for items not in options
  • Items are known at parse time but haven't been searched via onSearch yet

The item field is optional and fully backward-compatible — existing parseMatch implementations that only return displayText and key continue to work unchanged.

Headless Usage

Use useMentionEngine directly for full control over rendering:

import { useMentionEngine } from "mentionize";

const engine = useMentionEngine({
  triggers: [userTrigger],
  value,
  onChange: setValue,
});

// engine.visible          - display text
// engine.mentions         - active mentions with positions
// engine.activeTrigger    - currently active trigger (or null)
// engine.filteredOptions  - filtered suggestions
// engine.handleTextChange(text, caretPos)
// engine.handleKeyDown(event, textarea)
// engine.selectOption(item, textarea)
// engine.getItemForMention(triggerChar, key) - look up cached item for a mention

Styling

Mentionize uses a transparent textarea overlaid on a highlighted div. Apply styles via className props:

<MentionInput
  className="my-container"
  inputClassName="my-textarea"
  highlighterClassName="my-highlighter"
  dropdownClassName="my-dropdown"
  triggers={[trigger]}
/>

Conditional Mention Styling

Use a function for mentionClassName to style mentions dynamically based on the underlying item data:

import type { MentionItemData } from "mentionize";

const userTrigger: MentionTrigger<User> = {
  trigger: "@",
  mentionClassName: (mention: MentionItemData) => {
    const user = mention.item as User;
    switch (user?.role) {
      case "Engineer": return "mention-engineer";
      case "Designer": return "mention-designer";
      case "PM":       return "mention-pm";
      default:         return "mention-user";
    }
  },
  // Apply the same conditional styling to dropdown options
  optionClassName: (user) => {
    switch (user.role) {
      case "Engineer": return "mention-engineer";
      case "Designer": return "mention-designer";
      case "PM":       return "mention-pm";
      default:         return "mention-user";
    }
  },
  // ...other config
};

The MentionItemData object contains key, displayText, trigger, and item (the original cached item). Use optionClassName (string or function receiving the item directly) to apply matching styles to dropdown options.

Action Triggers

Use onSelect to create triggers that run an action instead of inserting a mention. The callback receives the selected item and returns a string to insert as plain text, or null to cancel:

const commandTrigger: MentionTrigger<Command> = {
  trigger: "/",
  displayText: (cmd) => cmd.label,
  // serialize/pattern/parseMatch still needed for the dropdown
  serialize: (cmd) => `/[${cmd.label}](cmd:${cmd.id})`,
  pattern: /\/\[([^\]]+)\]\(cmd:([^)]+)\)/g,
  parseMatch: (match) => ({ displayText: match[1]!, key: match[2]! }),
  options: [
    { id: "date", label: "Insert Date" },
    { id: "emoji", label: "Pick Emoji" },
  ],
  onSelect: async (cmd) => {
    if (cmd.id === "date") return new Date().toLocaleDateString();
    if (cmd.id === "emoji") {
      // simulate async work
      await new Promise((r) => setTimeout(r, 500));
      return "🎉";
    }
    return null; // cancel — nothing inserted
  },
};

When onSelect is defined, selecting an option calls the function instead of inserting a mention. The trigger text and query are replaced by the returned string.

Per-trigger mention highlights can be styled via mentionClassName:

const trigger: MentionTrigger<User> = {
  trigger: "@",
  mentionClassName: "mention-user",
  // ...
};

Tailwind CSS

<MentionInput
  className="relative rounded-lg border border-gray-300 bg-white focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-200"
  inputClassName="w-full border-none outline-none bg-transparent text-sm leading-relaxed"
  highlighterClassName="text-sm leading-relaxed text-gray-900"
  dropdownClassName="bg-white border border-gray-200 rounded-lg shadow-lg"
  triggers={[userTrigger, tagTrigger]}
/>

Style mention highlights with Tailwind by referencing a utility class in mentionClassName:

const userTrigger: MentionTrigger<User> = {
  trigger: "@",
  mentionClassName: "bg-blue-100 text-blue-700 rounded px-0.5",
  // ...
};

const tagTrigger: MentionTrigger<Tag> = {
  trigger: "#",
  mentionClassName: "bg-green-100 text-green-700 rounded px-0.5",
  // ...
};

About

A dependency-free React mention input with support for multiple triggers, async search, and full customization

Topics

Resources

License

Stars

Watchers

Forks

Contributors