Headless, zero-dependency TypeScript mention editor
Built on contentEditable — works with React, Vue 3, or vanilla JS.
- 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 - Keyboard-first —
@to open,↑↓to navigate,Enter/Tabto select,Escapeto close - Simple callbacks —
onSubmitgives youtextdirectly, plusnodesandmentionedUsersinmeta - Custom palettes — per-user colors or a shared palette
- Persistence format —
@{userId}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' }];| 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?) |
Stored string to (string | HTMLElement)[] |
renderCommentMessageToHTML(msg, users, palette?) |
Stored string to HTML string |
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
color?: string; // CSS color — overrides palette
[key: string]: unknown;
}
type TextNode = { type: 'text'; text: string };
type MentionNode = { type: 'mention'; user: MentionUser; displayName: string };
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/vue/basic.vue |
Drop-in <MentionInput>, @submit/@change emits |
examples/vue/with-composable.vue |
useMentionEditor, reactive computed users, team filter |
MIT (c) Amay Churi