MentionPlugin
@mentions, #tags, and custom trigger-based mentions.
Import
import { MentionPlugin } from "@blokhaus/core";Overview
The MentionPlugin provides a fully extensible mention system supporting @ (users/entities), # (tags/pages), and any custom trigger character. When a trigger character is typed at a word boundary, the plugin opens a keyboard-navigable Radix popover with search results from the matching provider. On selection, the trigger text is replaced with a persistent, non-editable MentionNode in the AST.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
providers | MentionProvider[] | (required) | Array of mention providers, each defining a trigger character and search behavior. |
The MentionProvider interface
interface MentionProvider {
/** The character that triggers this provider (e.g., '@', '#'). */
trigger: string;
/**
* Called when the user types after the trigger character.
* Return a promise resolving to matching items. Return an empty array for no results.
*/
onSearch: (query: string) => Promise<MentionItem[]>;
/**
* Renders a single item in the dropdown.
* Keep it lightweight -- this renders on every keystroke.
*/
renderItem: (item: MentionItem) => React.ReactNode;
/**
* Called when the user selects an item. Return the Lexical node to insert.
* The engine handles replacing the trigger + query text with this node.
*/
onSelect: (item: MentionItem) => LexicalNode;
}The MentionItem interface
interface MentionItem {
/** Unique identifier for the mentioned entity. */
id: string;
/** Display label shown in the dropdown and in the rendered chip. */
label: string;
/** Optional secondary text rendered below the label in the dropdown. */
meta?: string;
/** Optional URL to an avatar or icon rendered in the dropdown item. */
icon?: string;
}Usage
Basic setup with user mentions
"use client";
import { EditorRoot, MentionPlugin } from "@blokhaus/core";
import { $createMentionNode } from "@blokhaus/core";
const userProvider = {
trigger: "@",
onSearch: async (query: string) => {
const response = await fetch(
`/api/users/search?q=${encodeURIComponent(query)}`,
);
const users = await response.json();
return users.map(
(user: { id: string; name: string; email: string; avatar: string }) => ({
id: user.id,
label: user.name,
meta: user.email,
icon: user.avatar,
}),
);
},
renderItem: (item) => (
<div className="flex items-center gap-2">
{item.icon && (
<img src={item.icon} alt="" className="w-5 h-5 rounded-full" />
)}
<div>
<div className="font-medium">{item.label}</div>
{item.meta && (
<div className="text-xs text-muted-foreground">{item.meta}</div>
)}
</div>
</div>
),
onSelect: (item) =>
$createMentionNode({
id: item.id,
label: item.label,
trigger: "@",
}),
};
export default function EditorPage() {
return (
<EditorRoot namespace="my-editor">
<MentionPlugin providers={[userProvider]} />
</EditorRoot>
);
}Multiple providers
const tagProvider = {
trigger: "#",
onSearch: async (query: string) => {
const response = await fetch(
`/api/tags/search?q=${encodeURIComponent(query)}`,
);
return response.json();
},
renderItem: (item) => (
<div className="flex items-center gap-2">
<span className="text-blokhaus-muted-foreground">#</span>
<span>{item.label}</span>
</div>
),
onSelect: (item) =>
$createMentionNode({
id: item.id,
label: item.label,
trigger: "#",
}),
};
<MentionPlugin providers={[userProvider, tagProvider]} />;Dropdown behavior
The mention dropdown is implemented using @radix-ui/react-popover and supports full keyboard navigation:
| Key | Action |
|---|---|
| Arrow Down | Move selection to next item |
| Arrow Up | Move selection to previous item |
| Enter | Select the highlighted item and insert the MentionNode |
| Escape | Dismiss the dropdown and leave the trigger text as plain text |
| Backspace (past trigger) | Dismiss the dropdown |
Focus remains in the editor at all times -- it is never transferred to the popover. This allows the user to continue typing to refine the search query while the dropdown is open.
The MentionNode
The MentionNode is a Lexical DecoratorNode that stores { id, label, trigger } and renders as a styled, non-editable inline chip. It supports:
- Full
exportJSON/importJSONfor persistence. - Focusable but not editable. Pressing Delete or Backspace when the node is selected removes it from the AST.
- Graceful handling of stale references (when a mentioned entity no longer exists in the host app).
Creating a MentionNode programmatically
import { $createMentionNode, $isMentionNode } from "@blokhaus/core";
editor.update(() => {
const mention = $createMentionNode({
id: "user-123",
label: "Alice",
trigger: "@",
});
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertNodes([mention]);
}
});Type guard
import { $isMentionNode } from "@blokhaus/core";
if ($isMentionNode(node)) {
console.log("Mention:", node.getTrigger(), node.getLabel());
}A mention is triggered when the user types a trigger character at a position preceded by a space or at the start of a line. Trigger characters in the middle of words (e.g., typing "email@address") do not activate the mention dropdown.
Keep renderItem lightweight. This function is called on every keystroke
while the dropdown is open. Avoid expensive computations or heavy DOM trees
inside the render callback.
Related
- Mentions Guide -- Extended tutorial with async patterns
- Input Rules -- The engine that detects trigger characters