Headless, zero-dependency TypeScript mention editor
Built on contentEditable — works with React, Vue 3, or vanilla JS.
▶ Live demo — interactive React + Vue examples: multiple triggers, async search, creatable tags, hovercards, themes, and a rendered preview of each submitted comment.
- Zero dependencies — no framework required for the core
- Dual CJS + ESM builds with full TypeScript types
- React —
<MentionInput />component anduseMentionEditor()hook - Vue 3 —
<MentionInput />component anduseMentionEditor()composable - Headless — renders a plain
<div>, style with Tailwind / MUI / shadcn / anything - Multiple triggers —
@people,#tags,/commands,:emoji, each with its own data, colors, and filter; tags render as label pills, people as avatars - Async suggestions — fetch results from a server as you type (debounced, with a loading state)
- Creatable items — offer a "Create …" row so users can add a new
#tagon the fly - Slash commands — a trigger can run an action (insert text, open a dialog) instead of inserting a chip
- Hover user-info cards — hover a mention to reveal avatar, meta, and copyable fields
- Themeable —
--mk-*CSS variables (light/dark presets) or athemeobject; per-user or shared palette - Controlled or uncontrolled — drive it with a
valueprop, or leave it self-managed - Accessible — ARIA combobox semantics (
aria-expanded/aria-controls/aria-activedescendant), keyboard-first - Persistence format —
@{userId}/#{tagId}tokens for easy storage and re-render
# npm
npm install @cursortag/mention-kit
# yarn
yarn add @cursortag/mention-kit
# pnpm
pnpm add @cursortag/mention-kitReact and Vue are optional peer dependencies — install only what you use:
# React
yarn add @cursortag/mention-kit react
# Vue
yarn add @cursortag/mention-kit vueimport { MentionInput } from '@cursortag/mention-kit/react';
const users = [
{ id: 'u1', name: 'Alice Johnson', meta: 'Engineering' },
{ id: 'u2', name: 'Bob Smith', meta: 'Design' },
];
function CommentBox() {
return (
<MentionInput
users={users}
placeholder="Write a comment… (@ to mention)"
onSubmit={(text) => console.log(text)}
className="rounded border p-2 min-h-[80px]"
/>
);
}<script setup lang="ts">
import { MentionInput } from '@cursortag/mention-kit/vue';
const users = [
{ id: 'u1', name: 'Alice Johnson', meta: 'Engineering' },
{ id: 'u2', name: 'Bob Smith', meta: 'Design' },
];
</script>
<template>
<MentionInput
:users="users"
placeholder="Write a comment…"
class="rounded border p-2 min-h-[80px]"
@submit="(text) => console.log(text)"
/>
</template>import { createMentionEditor } from '@cursortag/mention-kit';
const editor = createMentionEditor({
container: document.getElementById('editor')!,
users: [
{ id: 'u1', name: 'Alice Johnson' },
{ id: 'u2', name: 'Bob Smith' },
],
placeholder: 'Write a comment…',
onSubmit: (text, { mentionedUsers }) => {
console.log(text); // "Hey @Alice Johnson, check this"
console.log(mentionedUsers); // [{ id: 'u1', name: 'Alice Johnson', ... }]
},
});
// Cleanup
editor.destroy();All callbacks receive text as the first argument and an optional meta object as the second:
onChange?: (text: string, meta: EditorCallbackMeta) => void;
onSubmit?: (text: string, meta: EditorCallbackMeta) => void;| Argument | Type | Description |
|---|---|---|
text |
string |
Plain text with mentions as @displayName |
meta.nodes |
EditorNode[] |
Full structured document (for storage/serialization) |
meta.mentionedUsers |
MentionUser[] |
De-duplicated list of mentioned users |
Simple usage — just use text:
onSubmit={(text) => saveComment(text)}Power-user usage — destructure meta when needed:
onSubmit={(text, { nodes, mentionedUsers }) => {
saveComment(text);
notifyUsers(mentionedUsers.map(u => u.id));
storeNodes(nodes); // for re-rendering later
}}import { useRef } from 'react';
import {
MentionInput,
type MentionEditorInstance,
} from '@cursortag/mention-kit/react';
function CommentBox() {
const ref = useRef<MentionEditorInstance>(null);
return (
<>
<MentionInput
ref={ref}
users={users}
placeholder="Write a comment…"
onSubmit={(text, { mentionedUsers }) => {
console.log(text, mentionedUsers);
ref.current?.clear();
}}
className="rounded border border-gray-300 p-3 min-h-[80px] text-sm"
/>
<button onClick={() => ref.current?.clear()}>Clear</button>
</>
);
}Props
| Prop | Type | Description |
|---|---|---|
users |
MentionUser[] |
List of mentionable users |
placeholder |
string |
Placeholder text |
onSubmit |
(text, meta) => void |
Called on Enter |
onChange |
(text, meta) => void |
Called on every edit |
disabled |
boolean |
Disables editing |
maxSuggestions |
number |
Max dropdown items (default 8) |
palette |
string[] |
Fallback colors for user chips |
defaultNodes |
EditorNode[] |
Initial content |
className |
string |
CSS class on the container div |
style |
CSSProperties |
Inline style on the container div |
renderUser |
(user, selected) => HTMLElement |
Custom dropdown row renderer |
Ref methods (useRef<MentionEditorInstance>)
| Method | Description |
|---|---|
getNodes() |
Returns current document as EditorNode[] |
setNodes(nodes, emit?) |
Replace content; pass true to fire onChange |
clear() |
Clear all content |
focus() |
Move focus into the editor |
setPlaceholder(text) |
Update placeholder after mount |
Use this when you need to embed the editor inside a MUI <Box>, shadcn <Textarea>, or any element you control.
import { useMentionEditor } from '@cursortag/mention-kit/react';
function MyEditor() {
const editor = useMentionEditor({
users,
onChange: (text) => console.log(text),
onSubmit: (text) => {
save(text);
editor.clear();
},
});
return (
<div
ref={editor.containerRef}
className="rounded border border-gray-300 p-3 min-h-[80px]"
/>
);
}MUI example
<Box
ref={editor.containerRef}
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
minHeight: 80,
}}
/>shadcn / Radix example
<div
ref={editor.containerRef}
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
'ring-offset-background focus-within:ring-2 focus-within:ring-ring',
)}
/>Returns
| Field | Type | Description |
|---|---|---|
containerRef |
Ref<HTMLDivElement> |
Attach to your container element |
getNodes() |
() => EditorNode[] |
Read current content |
setNodes(nodes, emit?) |
function |
Replace content |
clear() |
function |
Clear all content |
focus() |
function |
Focus the editor |
setPlaceholder(text) |
function |
Update placeholder |
<script setup lang="ts">
import { ref } from 'vue';
import {
MentionInput,
type MentionEditorInstance,
} from '@cursortag/mention-kit/vue';
const editorRef = ref<MentionEditorInstance | null>(null);
</script>
<template>
<MentionInput
ref="editorRef"
:users="users"
placeholder="Write a comment…"
class="rounded border border-gray-300 p-3 min-h-[80px] text-sm"
@submit="
(text) => {
save(text);
editorRef?.clear();
}
"
@change="(text) => console.log(text)"
/>
<button @click="editorRef?.clear()">Clear</button>
</template>Props
| Prop | Type | Description |
|---|---|---|
users |
MentionUser[] |
List of mentionable users |
placeholder |
string |
Placeholder text |
disabled |
boolean |
Disables editing |
maxSuggestions |
number |
Max dropdown items (default 8) |
palette |
string[] |
Fallback colors for user chips |
defaultNodes |
EditorNode[] |
Initial content |
Emits
| Event | Arguments | Description |
|---|---|---|
change |
(text: string, meta: EditorCallbackMeta) |
Fires on every edit |
submit |
(text: string, meta: EditorCallbackMeta) |
Fires on Enter |
Exposed methods (via template ref)
Same as the React ref methods — getNodes, setNodes, clear, focus, setPlaceholder.
<script setup lang="ts">
import { computed } from 'vue';
import { useMentionEditor } from '@cursortag/mention-kit/vue';
const editor = useMentionEditor({
get users() {
return filteredUsers.value;
},
onSubmit: (text) => {
save(text);
editor.clear();
},
});
</script>
<template>
<div ref="editor.containerRef" class="rounded border p-3 min-h-[80px]" />
</template>Element Plus example
<el-input :ref="editor.containerRef" type="textarea" :rows="3" />Vuetify example
<v-textarea :ref="editor.containerRef" variant="outlined" />These are standalone exports — use them anywhere, no editor instance needed.
Converts an EditorNode[] to a plain text string. Mentions become @displayName.
import { serializeToText } from '@cursortag/mention-kit';
const text = serializeToText(nodes);
// "Hey @Alice Johnson, check this PR"Converts an EditorNode[] to a markdown-style string with user IDs. Best for storage — you can re-render it later.
import { serializeToMarkdown } from '@cursortag/mention-kit';
const md = serializeToMarkdown(nodes);
// "Hey @[Alice Johnson](u1), check this PR"Takes a stored @{userId} message string and returns an array of text strings and HTMLElement chips. Use this to display stored messages in a non-editable context.
import { renderCommentMessage } from '@cursortag/mention-kit';
const stored = 'Great work @{u1}, please check with @{u2}';
const parts = renderCommentMessage(stored, users);
// [ 'Great work ', <span>Alice Johnson</span>, ', please check with ', <span>Bob Smith</span>, '' ]
// Append to DOM
parts.forEach((part) => {
container.appendChild(
typeof part === 'string' ? document.createTextNode(part) : part,
);
});Same as renderCommentMessage, but returns a single HTML string. Great for emails, server-side rendering, or dangerouslySetInnerHTML.
import { renderCommentMessageToHTML } from '@cursortag/mention-kit';
const html = renderCommentMessageToHTML('Hey @{u1}!', users);
// '<span style="...">Alice Johnson</span>'
// In React (use with caution):
<div dangerouslySetInnerHTML={{ __html: html }} />The built-in array of hex colors used when a user has no color property. Export it to extend or override.
import { DEFAULT_MENTION_PALETTE } from '@cursortag/mention-kit';
// Extend with your brand colors
const palette = [...DEFAULT_MENTION_PALETTE, '#f59e0b', '#ec4899'];
createMentionEditor({ ..., palette });Mentions are stored as @{userId} tokens. Save the serialised string and re-render it later:
import { serializeToMarkdown, renderCommentMessageToHTML } from '@cursortag/mention-kit';
// 1. User submits a comment — store the markdown
onSubmit={(text, { nodes }) => {
const stored = serializeToMarkdown(nodes);
// "Great work @[Alice Johnson](u1), please check with @[Bob Smith](u2)."
db.save(stored);
}}
// 2. Later, re-render the stored string to HTML
const html = renderCommentMessageToHTML(stored, users);| Key | Action |
|---|---|
@ |
Open mention dropdown |
↑ / ↓ |
Navigate dropdown |
Enter / Tab |
Select highlighted user |
Escape |
Close dropdown |
Enter |
Submit (calls onSubmit) |
Shift+Enter |
Insert newline |
Backspace |
On chip: shrinks name, then removes |
import { DEFAULT_MENTION_PALETTE } from '@cursortag/mention-kit';
// Custom palette
createMentionEditor({ ..., palette: ['#e11d48', '#0ea5e9', '#16a34a'] });
// Extend the default
createMentionEditor({ ..., palette: [...DEFAULT_MENTION_PALETTE, '#f59e0b'] });
// Per-user color (takes precedence over palette)
const users = [{ id: 'u1', name: 'Alice', color: '#7c3aed' }];By default the editor uses a single @ trigger backed by users. Pass
triggers to handle several — @ people, # tags, / commands, : emoji —
each with its own data source, colors, and matching. triggers fully replaces
the users shorthand.
createMentionEditor({
container,
get users() {
return [];
}, // ignored when `triggers` is set
triggers: [
{ trigger: '@', items: people, color: '#7c3aed' },
{ trigger: '#', items: tags, color: '#0891b2', label: 'Add a tag' },
{
// Async search — items is a function returning a Promise.
trigger: '/',
debounce: 200, // wait 200ms after typing stops
serverFiltered: true, // results are already filtered server-side
items: async (query) => {
const res = await fetch(`/api/commands?q=${query}`);
return res.json(); // MentionItem[] ({ id, name, ... })
},
},
],
});Each trigger accepts:
| Field | Type | Default | Description |
|---|---|---|---|
trigger |
string |
— | Single trigger char (@, #, /, :) |
items |
MentionItem[] | (query) => Items | Promise |
— | Static list or a (async) search function |
filter |
(item, query) => boolean |
name.includes |
Custom matcher |
serverFiltered |
boolean |
false |
Skip local filtering (source pre-filters) |
debounce |
number |
0 |
ms to debounce async items() |
minChars |
number |
0 |
Min query length before opening |
allowSpaces |
boolean |
false |
Allow spaces inside the query |
maxSuggestions |
number |
top-level | Max rows shown |
color |
string |
palette | Default chip color for this trigger |
label |
string |
"Mention someone" |
Dropdown header |
renderItem |
(item, selected) => HTMLElement |
— | Custom row renderer |
onSelect |
(item, ctx) => void |
— | Slash-command mode (see below) |
allowCreate |
boolean |
false |
Offer a "Create …" row for new items |
onCreate |
(query) => MentionItem |
— | Build the created item (implies create) |
createLabel |
(query) => string |
Create "<q>" |
Label for the "Create …" row |
Set allowCreate (or provide onCreate) and, when the query matches no existing
item, the dropdown offers a "Create …" row. Selecting it inserts a brand-new
mention chip — perfect for letting users add a #tag that doesn't exist yet.
{
trigger: '#',
items: tags,
allowCreate: true,
// Optional — mint your own id/color (default is { id: query, name: query }):
onCreate: (query) => ({ id: `tag:${query}`, name: query, color: '#0891b2' }),
}The created item is inserted like any other mention and persists as
#{tag:query}. To re-render stored content containing created items, include
them in triggerItems (accumulate them from onCreate).
Give a trigger an onSelect handler and selecting an item runs the callback
instead of inserting a chip — the typed trigger text is removed first. Use
ctx.insertText(text) to insert content at the caret. Perfect for / commands:
{
trigger: '/',
items: [
{ id: 'assign', name: 'assign', meta: 'Assign to a teammate' },
{ id: 'date', name: 'date', meta: 'Insert today' },
],
onSelect: (item, ctx) => {
if (item.id === 'date') ctx.insertText(new Date().toISOString().slice(0, 10));
else openAssignDialog(); // any side effect
},
}Mentions remember their trigger, so they persist as <trigger>{id} (e.g.
#{t1}) and round-trip. When re-rendering stored content that uses non-@
triggers, pass triggerItems so ids resolve to names:
// vanilla / Vue
renderCommentMessage(stored, people, palette, [{ trigger: '#', items: tags }]);
parsePersist(stored, people, [{ trigger: '#', items: tags }]);
// React
<RenderedMessage message={stored} users={people} triggerItems={[{ trigger: '#', items: tags }]} />React & Vue <MentionInput> accept triggers as a prop.
Drive the editor from state with a value prop (a persisted @{id} string).
Pair it with onChange + serializeToPersist. The editor only re-seeds when
value differs from its current content, so typing keeps its caret.
import { MentionInput, serializeToPersist } from '@cursortag/mention-kit/react';
function Controlled() {
const [value, setValue] = useState('Hi @{u1}');
return (
<MentionInput
users={users}
value={value}
onChange={(_text, { nodes }) => setValue(serializeToPersist(nodes))}
/>
);
}<!-- Vue -->
<MentionInput
:users="users"
:value="value"
@change="(_t, { nodes }) => (value = serializeToPersist(nodes))"
/>Works on the hook / composable too. Uncontrolled? Use defaultValue /
defaultNodes instead.
The editor follows the ARIA combobox pattern. The editable exposes
role="textbox", aria-multiline, and aria-autocomplete="list", and while the
suggestion list is open it sets aria-expanded, aria-controls (the listbox
id), and aria-activedescendant (the highlighted option id). The dropdown is a
role="listbox" with role="option" rows. Keyboard: ↑↓ navigate, Enter /
Tab select, Escape closes.
Reveal a rich profile card when a reader hovers a mention in a rendered message — avatar, meta, and any extra fields, each optionally copyable.
Give users the info you want to surface:
const users = [
{
id: 'u1',
name: 'Alice Johnson',
meta: 'Staff Engineer',
avatar: 'https://…/alice.png',
email: 'alice@acme.com', // shown as a copyable row
details: [
{ label: 'Team', value: 'Platform' },
{ label: 'Slack', value: '@alice', href: 'https://acme.slack.com/…' },
],
},
];React — just add the hovercard prop to <RenderedMessage />:
import { RenderedMessage } from '@cursortag/mention-kit/react';
<RenderedMessage message={stored} users={users} hovercard />
// Configure copy behavior, delays, or a fully custom card body:
<RenderedMessage
message={stored}
users={users}
hovercard={{ copyFields: true, copyUser: (u) => `${u.name} <${u.email}>` }}
/>Vanilla / Vue — render the message, then wire cards onto the container. It returns a cleanup function; call it on unmount.
import { renderCommentMessage, attachHovercards } from '@cursortag/mention-kit';
renderCommentMessage(stored, users).forEach((part) =>
container.append(
typeof part === 'string' ? document.createTextNode(part) : part,
),
);
const cleanup = attachHovercards(container, users, {
openDelay: 180, // ms before the card opens
closeDelay: 140, // ms before it closes
copyFields: true, // per-row copy buttons (default true)
copyUser: true, // "copy user" button (default true)
});
// later: cleanup();attachHovercards works on the output of renderCommentMessageToHTML too — the
mention spans carry data-mention-id, so you can inject the HTML and attach
cards afterwards. Copy buttons use navigator.clipboard and no-op where it is
unavailable.
| Option | Type | Default | Description |
|---|---|---|---|
openDelay |
number |
180 |
ms before the card opens on hover |
closeDelay |
number |
140 |
ms before the card closes on leave |
copyFields |
boolean |
true |
Copy button on each field row |
copyUser |
boolean | (user) => string |
true |
"Copy user" button + its text |
render |
(user) => HTMLElement |
— | Replace the entire card body |
theme |
MentionTheme |
— | Theme applied to the card |
className |
string |
— | Extra class appended to the card |
Chips and hovercards read their styling from --mk-* CSS custom properties. The
library's built-in look is the var() fallback, so the default appearance is
unchanged and per-user colors still win — you only override what you want.
Option A — plain CSS. Set the variables on any ancestor (chips) or on
.mk-hovercard (the floating card):
.comments {
--mk-chip-bg: #eef2ff;
--mk-chip-text: #4338ca;
--mk-chip-radius: 6px;
}
.mk-hovercard {
--mk-card-bg: #0b1220;
--mk-card-text: #e2e8f0;
--mk-card-border: #1e293b;
--mk-accent: #60a5fa;
}Option B — a theme object (React <RenderedMessage> / <MentionInput>,
Vue props, or createMentionEditor). A light / dark preset seeds the card;
any explicit key overrides it:
<RenderedMessage
message={stored}
users={users}
hovercard
theme={{ preset: 'dark' }}
/>;
createMentionEditor({
container,
users,
theme: { chipBg: '#eef2ff', chipRadius: 6 },
});| Theme key | CSS variable | Applies to |
|---|---|---|
preset |
(seeds card vars) | 'light' / 'dark' |
chipBg |
--mk-chip-bg |
chip background |
chipText |
--mk-chip-text |
chip text |
chipRadius |
--mk-chip-radius |
chip corners |
cardBg |
--mk-card-bg |
card background |
cardText |
--mk-card-text |
card text |
cardMuted |
--mk-card-muted |
labels / meta |
cardBorder |
--mk-card-border |
card border |
cardShadow |
--mk-card-shadow |
card shadow |
cardRadius |
--mk-card-radius |
card corners |
accent |
--mk-accent |
copy buttons / links |
resolveThemeVars(theme) returns the raw { '--mk-*': value } map and
applyTheme(el, theme) writes it onto an element, if you need to theme a
container yourself.
| Export | Description |
|---|---|
createMentionEditor(opts) |
Creates a vanilla editor instance |
serializeToText(nodes) |
Nodes to plain text string |
serializeToMarkdown(nodes) |
Nodes to @[name](id) markdown string |
renderCommentMessage(msg, users, palette?, triggerItems?) |
Stored string to (string | HTMLElement)[] |
renderCommentMessageToHTML(msg, users, palette?, triggerItems?) |
Stored string to HTML string |
parsePersist(raw, users, triggerItems?) |
Stored string to EditorNode[] |
attachHovercards(root, users, opts?) |
Wire hover user-info cards onto rendered mentions; returns cleanup |
resolveThemeVars(theme) |
Theme object to { '--mk-*': value } map |
applyTheme(el, theme) |
Write theme vars onto an element |
DEFAULT_MENTION_PALETTE |
Built-in color array |
interface MentionUser {
id: string;
name: string;
avatar?: string; // URL — shown in chip avatar
meta?: string; // Subtitle shown in dropdown + hovercard
color?: string; // CSS color — overrides palette
email?: string; // Copyable row in the hovercard
details?: MentionUserDetail[]; // Extra hovercard rows
[key: string]: unknown;
}
interface MentionUserDetail {
label: string;
value: string;
copyable?: boolean; // default true
href?: string; // renders value as a link (http(s)/mailto/tel)
}
type MentionItem = MentionUser; // items a trigger can suggest
interface MentionTrigger {
trigger: string; // single char: '@', '#', '/', ':'
items:
| MentionItem[]
| ((query: string) => MentionItem[] | Promise<MentionItem[]>);
filter?: (item: MentionItem, query: string) => boolean;
serverFiltered?: boolean; // items() already filtered — skip local filter
debounce?: number; // ms, for async items() (default 0)
minChars?: number; // default 0
allowSpaces?: boolean; // default false
maxSuggestions?: number;
color?: string;
label?: string;
renderItem?: (item: MentionItem, selected: boolean) => HTMLElement;
onSelect?: (item: MentionItem, ctx: TriggerActionContext) => void; // slash-command mode
allowCreate?: boolean; // offer a "Create …" row for unmatched queries
onCreate?: (query: string) => MentionItem; // build the created item
createLabel?: (query: string) => string; // "Create …" row label
}
interface TriggerActionContext {
trigger: string; // the trigger char that fired
insertText: (text: string) => void; // insert text at the caret
}
// Item lists per trigger char, for parsing/rendering stored multi-trigger content
interface TriggerItems {
trigger: string;
items: MentionItem[];
}
type TextNode = { type: 'text'; text: string };
type MentionNode = {
type: 'mention';
user: MentionUser;
displayName: string;
trigger?: string; // absent means '@'
};
type EditorNode = TextNode | MentionNode;
interface EditorCallbackMeta {
nodes: EditorNode[];
mentionedUsers: MentionUser[];
}
interface MentionEditorInstance {
getNodes: () => EditorNode[];
setNodes: (nodes: EditorNode[], emit?: boolean) => void;
focus: () => void;
clear: () => void;
destroy: () => void;
setPlaceholder: (text: string) => void;
}Full runnable examples live in examples/:
| File | What it shows |
|---|---|
examples/react/basic.tsx |
Drop-in <MentionInput>, submit text + mentionedUsers, clear |
examples/react/with-hook.tsx |
useMentionEditor hook, custom container, toolbar, live text + mentioned users |
examples/react/with-mui.tsx |
MUI <Box> shell, send button |
examples/react/with-hovercards.tsx |
<RenderedMessage hovercard>, copyable fields, light/dark theme toggle |
examples/vue/basic.vue |
Drop-in <MentionInput>, @submit/@change emits |
examples/vue/with-composable.vue |
useMentionEditor, reactive computed users, team filter |
examples/vue/with-hovercards.vue |
renderCommentMessage + attachHovercards, theme + cleanup on unmount |
MIT (c) Amay Churi