Lightweight @-mention autocomplete for <textarea> and contenteditable.
No dependencies. ~10 KB gzipped. TypeScript definitions included.
- Works with both
<textarea>andcontenteditableelements - Async search function with debounce and stale-request guard
- Scroll-based and keyboard-based pagination via
nextPageUrl - Keyboard navigation: Arrow keys, Enter, Tab, Escape (IME composition keys are not intercepted)
- Programmatic API:
push(),getMentions(),clear(),destroy() - Avatar support (image URL or auto-generated letter placeholder)
- Viewport-aware dropdown positioning (flips above cursor when near bottom)
- Animated dropdown appearance (CSS transition)
- ARIA combobox/listbox semantics synchronized with keyboard and pointer selection
- UMD module format (browser global, CommonJS, AMD)
<link rel="stylesheet" href="mention.css">
<script src="mention.js"></script>
<div id="editor" contenteditable="true"></div>
<script>
const mention = new MentionJS(document.getElementById('editor'), {
trigger: '@',
debounceDelay: 300,
noResultsText: 'Not found',
provideSearchContext: true,
searchFunction: async (query, nextPageUrl, context = {}) => {
const url = nextPageUrl || `/api/users?q=${encodeURIComponent(query)}`;
const res = await fetch(url, { signal: context.signal });
if (!res.ok) throw new Error(`Search failed (${res.status})`);
return res.json(); // { items: [{ id, name, avatar?, details? }], nextPageUrl }
},
onMentionSelect(data) {
console.log('Selected:', data.id, data.name);
},
});
</script>The example enables provideSearchContext because its callback declares a default third parameter. The request's AbortSignal is forwarded to fetch. The library passes nextPageUrl back to your callback for pagination, but does not fetch that URL itself.
npm install @shelamkoff/mentionjs// CommonJS
const MentionJS = require('@shelamkoff/mentionjs');// ES Module (with bundler)
import MentionJS from '@shelamkoff/mentionjs';CDN:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shelamkoff/mentionjs/dist/mention.min.css">
<script src="https://cdn.jsdelivr.net/npm/@shelamkoff/mentionjs/dist/mention.min.js"></script>Manual:
<link rel="stylesheet" href="mention.css">
<script src="mention.js"></script>const m = new MentionJS(element, options);element must be a <textarea> or an element with contenteditable="true".
| Option | Type | Default | Description |
|---|---|---|---|
trigger |
string |
'@' |
Exactly one non-whitespace Unicode grapheme that opens the dropdown |
searchFunction |
SearchFunction |
null |
Async search function (see below) |
provideSearchContext |
boolean |
false |
Always pass the optional third search callback argument for callbacks with default/rest parameters |
emitInputOnProgrammaticChange |
boolean |
false |
Dispatch input after push() and clear() (opt-in for backwards compatibility) |
allowSpacesInQuery |
boolean |
true in contenteditable; false in textarea |
Keep an active search across spaces in names, e.g. @Anna Ivanova; line breaks and tabs still end the query |
debounceDelay |
number |
300 |
Debounce delay in ms for non-empty queries |
noResultsText |
string |
'No results found' |
Text shown when search returns no items |
dropdownClass |
string |
'' |
Additional CSS class for the dropdown container |
onMentionSelect |
(data: { id, name }) => void |
null |
Callback when a mention is committed |
renderItem |
(data, index, isActive) => HTMLElement |
null |
Custom render function for dropdown items |
renderNoResults |
(noResultsText) => HTMLElement |
null |
Custom render function for the "no results" row |
renderLoading |
() => HTMLElement |
null |
Custom render function for the loading indicator |
type SearchFunction = (
query: string,
nextPageUrl?: string | null,
context?: { signal?: AbortSignal }
) => Promise<SearchResult | MentionItem[]>;Must return a Promise resolving to:
{
items: [
{ id: 1, name: 'Alice', avatar: '/img/alice.jpg', details: 'Developer' },
{ id: 2, name: 'Bob', details: 'Designer' },
],
nextPageUrl: '/api/users?q=a&page=2' // null when no more pages
}items[].id— unique identifier (string or number)items[].name— display name (required)items[].avatar— image URL (optional; letter placeholder is generated when absent)items[].details— secondary text line (optional)nextPageUrl— URL for the next page;nullmeans no more pages
You may also return a plain array of items (without pagination).
Empty-string queries (query === '') are executed immediately (no debounce) to show the initial list when the trigger character is typed.
To retain the original v1 behavior, contenteditable searches accept spaces by default, while textarea searches end at whitespace by default. Override either mode with allowSpacesInQuery: true or false. In contenteditable, browser-inserted non-breaking spaces (NBSP) are passed to searchFunction as ordinary spaces. With multi-word search enabled, a space does not end the token: choose a result with Enter, Tab, or click, or press Escape / move the caret outside to cancel. Newlines and tabs still terminate the query.
context.signal is aborted for an in-flight request when it is superseded, the dropdown closes, or the instance is destroyed. A request that has already completed is not retroactively aborted. Existing search functions accepting only query or (query, nextPageUrl) remain compatible. Set provideSearchContext: true to receive the third argument in a callback with default or rest parameters (which report a smaller JavaScript function.length).
All render functions are optional and should return an HTMLElement. A falsy return value or a thrown error falls back to the default renderer; thrown errors are logged as warnings rather than interrupting the dropdown or pagination.
renderItem(data, index, isActive)
Custom rendering for each dropdown item. The returned element automatically gets mention-item class and data-index attribute.
renderItem(data, index, isActive) {
const el = document.createElement('div');
el.className = 'mention-item' + (isActive ? ' mention-active' : '');
if (data.avatar) {
const img = document.createElement('img');
img.src = data.avatar;
img.alt = '';
img.className = 'mention-avatar';
el.appendChild(img);
}
const info = document.createElement('div');
info.className = 'mention-info';
const name = document.createElement('div');
name.className = 'mention-name';
name.textContent = data.name;
info.appendChild(name);
if (data.role) {
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = data.role;
info.appendChild(badge);
}
el.appendChild(info);
return el;
}renderNoResults(noResultsText)
Custom rendering for the empty-results state.
renderNoResults(text) {
const el = document.createElement('div');
el.className = 'mention-item mention-no-results';
el.textContent = text;
return el;
}renderLoading()
Custom rendering for the pagination loading indicator. The returned element automatically gets mention-loading class.
renderLoading() {
const el = document.createElement('div');
el.className = 'mention-loading';
el.innerHTML = '<div class="mention-item"><div class="spinner"></div></div>';
return el;
}Returns an array of all committed mentions.
Textarea returns UTF-16 code-unit offsets (as in textarea.selectionStart and selectionEnd, not Unicode grapheme counts):
[{ id: 1, name: 'Alice', start: 0, end: 6 }]ContentEditable returns id and name only:
[{ id: '1', name: 'Alice' }]Programmatically inserts a mention at the current cursor position, or at the end of the field if no cursor is active.
m.push({ id: 1, name: 'Alice' });Clears all content and committed mentions.
By default, push() and clear() preserve the legacy behavior of not dispatching input. Set emitInputOnProgrammaticChange: true to notify form/framework listeners of both operations. A normal dropdown selection always emits input.
Removes all event listeners and the dropdown element. Call before removing the host element from the DOM.
Static factory method. Equivalent to new MentionJS(element, options).
Import mention.css for default styles. All classes are customizable:
| Class | Description |
|---|---|
.mention-dropdown |
Dropdown container (positioned absolute, appended to <body>) |
.mention-dropdown.active |
Visible state (opacity 1, pointer-events auto) |
.mention-item |
Individual item row |
.mention-item.mention-active |
Highlighted item (keyboard or hover) |
.mention-item.mention-no-results |
"No results" row |
.mention-avatar |
Avatar <img> element |
.mention-avatar-placeholder |
Letter-circle fallback avatar |
.mention-info |
Text container (name + details) |
.mention-name |
Primary name text |
.mention-details |
Secondary details text |
.mention-loading |
Loading indicator row (pagination) |
.mention |
Committed mention <span> inside contenteditable |
.mention.active |
Active (being edited) mention span |
Textarea: Mentions are tracked as { id, name, start, end } objects with UTF-16, half-open [start, end) ranges. Native edits are tracked via beforeinput and reconciled after input. Assigning textarea.value programmatically does not emit input; getMentions() and push() conservatively reconcile changes and may drop an ID when identical visible text makes mention identity ambiguous. The mention text is displayed inline as @Name.
ContentEditable: Each mention is a <span class="mention"> with internal ownership metadata plus data-mention-id and data-mention-name. Active (in-progress) mentions have the .active class. Browser-driven input is reconciled after input, while operations that need atomic mention behavior are handled through beforeinput. Boundary Backspace/Delete preserves mention identity when a mention is nested inside formatting elements; ordinary adjacent text remains editable. In both modes, committing or pushing a mention immediately before punctuation (,;!?) does not add an extra space. Composition-mode keyboard events are reserved for the IME rather than autocomplete selection.
| File | Description |
|---|---|
mention.js |
Library source |
mention.css |
Default stylesheet |
mention.d.ts |
TypeScript type definitions |
dist/mention.min.js |
Generated minified JS (npm run build / prepack) |
dist/mention.min.css |
Generated minified CSS (npm run build / prepack) |
index.html |
Interactive demo and GitHub Pages root entrypoint |
.github/workflows/ci.yml |
Tests and build checks |
.github/workflows/pages.yml |
GitHub Pages deployment after successful CI |
dist/mention.d.ts |
Generated copy of the TypeScript declarations |
The dist/ directory is generated and is not stored in Git. It is included in published npm packages because prepack runs the build automatically.
Requires modern beforeinput, Selection/Range, and AbortController support. Native CI smoke tests run source and the minified npm build in current Chrome and Firefox. Safari/WebKit is not part of the automated browser matrix. IE11 is not supported.
For complete Unicode grapheme segmentation, use an environment with Intl.Segmenter. When it is absent, MentionJS uses a best-effort built-in fallback for common combining marks and emoji sequences; it does not implement all Unicode grapheme-break rules (including some Indic conjuncts and Hangul Jamo).
MIT
contenteditable can import pre-existing mention spans carrying data-mention-id and data-mention-name. Such markup is treated as application-provided metadata, not as an authenticated identity. Sanitize untrusted HTML before inserting it into an editing host and validate mention IDs server-side before performing privileged actions. Custom render functions are also responsible for safe handling of untrusted content.
Directly rewriting contenteditable HTML through browser editing commands or external DOM operations can bypass beforeinput. If the visible content of a committed span changes, MentionJS conservatively invalidates that span's identity. Use the public API for mention insertion and validate IDs rather than assuming text-matched identities survive arbitrary HTML rewrites.
index.html is the interactive demo and uses the local mention.js and mention.css files. It demonstrates abortable search, pagination, keyboard selection, the public push() / clear() / getMentions() methods, and committed IDs / offsets. The Before comma controls reset their corresponding field and demonstrate punctuation-safe insertion through the public API.
The Deploy demo to Pages workflow publishes the demo and minified JS/CSS after a successful CI run triggered by a push to main. The GitHub Pages site has been deployed successfully. Pages uses Settings → Pages → Build and deployment → Source: GitHub Actions; no gh-pages branch is required.