@cursortag/mention-kit
v0.1.2
Published
Headless zero-dependency TypeScript mention editor for React, Vue 3, and vanilla JS
Downloads
345
Maintainers
Readme
mention-kit
Features
- 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
Installation
# 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 vueQuick start
React
import { 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]"
/>
);
}Vue 3
<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>Vanilla JS
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();Callback signature
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
}}React
<MentionInput /> — drop-in component
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 |
useMentionEditor() — hook for custom containers
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 |
Vue 3
<MentionInput /> — drop-in component
<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.
useMentionEditor() — composable for custom containers
<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" />Utility functions
These are standalone exports — use them anywhere, no editor instance needed.
serializeToText(nodes)
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"serializeToMarkdown(nodes)
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"renderCommentMessage(message, users, palette?)
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,
);
});renderCommentMessageToHTML(message, users, palette?)
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 }} />DEFAULT_MENTION_PALETTE
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 });Persistence
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);Keyboard shortcuts
| 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 |
Custom palette
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' }];API reference
Core (@cursortag/mention-kit)
| 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 |
Types
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;
}Examples
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 |
License
MIT (c) Amay Churi
