npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mentionize

v0.0.7

Published

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

Readme

License

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",
  // ...
};