@m1z23r/ngx-mentions
v0.1.3
Published
A signals-first Angular mentions editor with atomic pills, multiple triggers, async sources and template variables
Maintainers
Readme
@m1z23r/ngx-mentions
A signals-first Angular mentions editor. It renders atomic, non-editable pills inside a
contenteditable surface, supports any number of triggers at once (including multi-character
ones like {{ for template variables), and can back each trigger with a static array or an
async source. Custom item templates are supported per trigger or globally. The library has zero
runtime dependencies beyond Angular itself, uses no rxjs, and is zoneless-compatible.
Install
npm i @m1z23r/ngx-mentionsPeer dependencies: @angular/common and @angular/core at ^21.0.0.
Quick start
import { Component, signal } from '@angular/core';
import { IMentionItem, IMentionTriggerConfig, NgxMentionsComponent } from '@m1z23r/ngx-mentions';
@Component({
selector: 'app-comment-box',
standalone: true,
imports: [NgxMentionsComponent],
template: `<ngx-mentions [(value)]="body" [triggers]="triggers" placeholder="Say something..." />`,
})
export class CommentBox {
protected readonly body = signal('');
private readonly users: IMentionItem[] = [
{ id: 'u1', label: 'John Doe' },
{ id: 'u2', label: 'Jane Smith' },
];
protected readonly triggers: IMentionTriggerConfig[] = [{ trigger: '@', source: this.users }];
}Typing Hey @ opens a dropdown filtered by what follows. Picking "John Doe" inserts an atomic
pill and body() becomes the markup string:
Hey @[John Doe](u1)!The markup is what you persist. Use parseMentions or mentionsToPlainText (documented below)
to turn it back into structured segments or a human-readable string wherever you render it.
Trigger configuration
Each entry in the triggers input is an IMentionTriggerConfig:
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| trigger | string | required | The character(s) that open the dropdown, e.g. @, #, {{. Must be unique across configs. |
| suffix | string | none | Closes a multi-char trigger, e.g. }}. When set, the item is serialized as trigger + id + suffix instead of the default markdown-like format. |
| source | IMentionItem[] \| (query: string) => IMentionItem[] \| Promise<IMentionItem[]> | required | A static list (filtered client-side by label) or a function returning results for a query, sync or async. |
| displayWith | (item: IMentionItem) => string | item.label | Text rendered inside the pill. |
| serialize | (item: IMentionItem) => string | @[label](id) or trigger+id+suffix | How a chosen item is written into the stored value string. |
| pattern | RegExp | derived from trigger/suffix | Regex used to find and parse mentions back out of a stored value. |
| deserialize | (match: RegExpExecArray) => IMentionItem | derived | Builds an IMentionItem from a pattern match. |
| pillClass | string | none | Extra CSS class applied to pills produced by this trigger, for per-trigger styling. |
| minChars | number | 0 | Minimum characters typed after the trigger before search runs. |
| allowSpaces | boolean | false | Whether the query may contain spaces before the trigger session is cancelled. |
| debounceMs | number | 150 | Debounce applied before calling an async source. |
Template variables
Use a suffix to support multi-character wrappers such as {{ }} template variables:
const variableTrigger: IMentionTriggerConfig = {
trigger: '{{',
suffix: '}}',
source: [
{ id: 'firstName', label: 'First Name' },
{ id: 'lastName', label: 'Last Name' },
],
};Picking "First Name" serializes as {{firstName}} instead of the default @[label](id) shape,
which keeps the stored value compatible with plain template engines.
Async sources
Pass a function instead of an array to fetch results as the user types:
const userTrigger: IMentionTriggerConfig = {
trigger: '@',
debounceMs: 200,
source: (query: string): Promise<IMentionItem[]> =>
fetch(`/api/users?q=${encodeURIComponent(query)}`).then((res) => res.json()),
};The dropdown shows a built-in loading indicator (three animated dots) while the promise is
pending. Requests are debounced by debounceMs, and results from a stale request (superseded by
newer keystrokes) are discarded automatically, so out-of-order responses never overwrite the
latest search.
Custom serialization
serialize, pattern, and deserialize work together and should be overridden as a set so
parsing round-trips correctly:
const ticketTrigger: IMentionTriggerConfig = {
trigger: '#',
source: tickets,
serialize: (item) => `#TICKET-${item.id}`,
pattern: /#TICKET-(\w+)/g,
deserialize: (match) => tickets.find((t) => t.id === match[1]) ?? { id: match[1], label: match[1] },
};Component API
<ngx-mentions> (NgxMentionsComponent):
| Member | Kind | Type | Description |
| --- | --- | --- | --- |
| value | input/output (model) | string | Two-way bound markup string, e.g. [(value)]="body". |
| triggers | input (required) | IMentionTriggerConfig[] | The list of triggers the editor listens for. |
| placeholder | input | string | Placeholder text shown when empty. |
| disabled | input | boolean | Disables editing and closes any open dropdown. |
| multiline | input | boolean | When false, Enter emits submitted instead of inserting a line break. |
| segments | signal (readonly) | MentionSegment[] | value parsed into text and mention segments. |
| plainText | signal (readonly) | string | value rendered as human-readable text (pills as their display label). |
| mentionAdded | output | IMentionSegment | Emitted when a pill is inserted, by picking or auto-converting a suffix trigger. |
| mentionRemoved | output | IMentionSegment | Emitted when a pill is deleted. |
| submitted | output | void | Emitted on Enter when multiline is false. |
| insertMention(item) | method | (item: IMentionItem) => void | Programmatically insert an item into the currently open trigger session. |
Custom item templates
Provide an ng-template with ngxMentionItem to override how dropdown items render. Give it a
trigger string to scope it to that trigger only, or leave it empty (or omit the value) to use it
as the fallback for every trigger that has no dedicated template:
<ngx-mentions [(value)]="body" [triggers]="triggers">
<ng-template ngxMentionItem="@" let-item let-query="query" let-trigger="trigger">
<img [src]="item.data.avatar" alt="" />
<span>{{ item.label }}</span>
</ng-template>
</ngx-mentions>The template context is IMentionItemTemplateContext: $implicit (the IMentionItem), query
(current search text), and trigger (the trigger string that opened the dropdown).
Helpers
parseMentions(value, triggers) turns a stored markup string into MentionSegment[] (a union of
ITextSegment and IMentionSegment). mentionsToPlainText(value, triggers) renders the same
value as a plain string using each trigger's displayWith. Both are useful for rendering a
previously stored message outside of the editor:
import { mentionsToPlainText, parseMentions } from '@m1z23r/ngx-mentions';
const stored = 'Hey @[John Doe](u1), welcome to {{company}}!';
const segments = parseMentions(stored, triggers);
const readable = mentionsToPlainText(stored, triggers);
// readable === 'Hey John Doe, welcome to Company!'segments can be iterated with @for in a template to render text and mention chips with your
own markup, without instantiating the editor at all.
Theming
All visual styling is controlled by CSS custom properties, so no ::ng-deep or SCSS overrides
are needed:
| Variable | Fallback |
| --- | --- |
| --ngx-mentions-editor-color | inherit |
| --ngx-mentions-editor-bg | #fff |
| --ngx-mentions-editor-border | #d1d5db |
| --ngx-mentions-editor-radius | 6px |
| --ngx-mentions-editor-padding | 0.5em 0.75em |
| --ngx-mentions-editor-min-height | 2.5em |
| --ngx-mentions-focus-border | #6366f1 |
| --ngx-mentions-focus-ring-width | 2px |
| --ngx-mentions-focus-ring-color | rgba(99, 102, 241, 0.25) |
| --ngx-mentions-placeholder-color | #9ca3af |
| --ngx-mentions-disabled-opacity | 0.6 |
| --ngx-mentions-editor-disabled-bg | #f3f4f6 |
| --ngx-mentions-pill-padding | 0 0.25em |
| --ngx-mentions-pill-radius | 4px |
| --ngx-mentions-pill-bg | #e0e7ff |
| --ngx-mentions-pill-color | #3730a3 |
| --ngx-mentions-dropdown-z | 1000 |
| --ngx-mentions-dropdown-min-width | 180px |
| --ngx-mentions-dropdown-max-width | 320px |
| --ngx-mentions-dropdown-max-height | 240px |
| --ngx-mentions-dropdown-bg | #fff |
| --ngx-mentions-dropdown-border | #e5e7eb |
| --ngx-mentions-dropdown-radius | 8px |
| --ngx-mentions-dropdown-shadow | 0 4px 12px rgba(0, 0, 0, 0.1) |
| --ngx-mentions-item-padding | 6px 10px |
| --ngx-mentions-item-active-bg | #eef2ff |
| --ngx-mentions-loading-gap | 4px |
| --ngx-mentions-loading-color | #9ca3af |
| --ngx-mentions-loading-dot-size | 5px |
.my-scope {
--ngx-mentions-pill-bg: #dcfce7;
--ngx-mentions-pill-color: #166534;
--ngx-mentions-focus-border: #22c55e;
}For per-trigger pill colors (rather than a global override), set pillClass on a trigger config
and style that class instead.
Known constraints
- With the default
@[label](id)format, itemidvalues should avoid). - With a
suffix-based format (e.g.{{ }}), itemidvalues should match[\w.-]+. - Two trigger configs must not share the same
triggerstring. - Undo/redo across a programmatically inserted pill (via
insertMentionor suffix auto-conversion) is best-effort; the browser's native undo stack does not always track DOM mutations made outside of direct typing.
Development
yarn install
yarn start # serve the demo app
yarn build:lib # production build of the library into dist/ngx-mentionsTo publish a new version:
yarn bv # bump patch version in root and library package.json
yarn build:lib
yarn publish:libLicense
MIT
