@m1z23r/ngx-mentions

August 13, 2026 ยท View on GitHub

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-mentions

Peer 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:

FieldTypeDefaultDescription
triggerstringrequiredThe character(s) that open the dropdown, e.g. @, #, {{. Must be unique across configs.
suffixstringnoneCloses a multi-char trigger, e.g. }}. When set, the item is serialized as trigger + id + suffix instead of the default markdown-like format.
sourceIMentionItem[] | (query: string) => IMentionItem[] | Promise<IMentionItem[]>requiredA static list (filtered client-side by label) or a function returning results for a query, sync or async.
displayWith(item: IMentionItem) => stringitem.labelText rendered inside the pill.
serialize(item: IMentionItem) => string@[label](id) or trigger+id+suffixHow a chosen item is written into the stored value string.
patternRegExpderived from trigger/suffixRegex used to find and parse mentions back out of a stored value.
deserialize(match: RegExpExecArray) => IMentionItemderivedBuilds an IMentionItem from a pattern match.
pillClassstringnoneExtra CSS class applied to pills produced by this trigger, for per-trigger styling.
minCharsnumber0Minimum characters typed after the trigger before search runs.
allowSpacesbooleanfalseWhether the query may contain spaces before the trigger session is cancelled.
debounceMsnumber150Debounce 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.

Typing a token out by hand also converts it to a pill as soon as the suffix is closed. The test is the trigger's pattern: whatever it claims in full - from the trigger through the closing suffix, with nothing left over - is deserialized and inserted, for a function source exactly as for a static array. That is deliberately the same question parseMentions asks of stored text, so what converts while typing is what would have come back as a pill on the next load. A token the pattern does not fully match stays plain text, which is what an unrecognized variable should look like.

Unlike picking from the dropdown, an auto-converted token gets no trailing space: the writer typed the whole thing and is already past it, so {{firstName}}, stays {{firstName}}, rather than becoming {{firstName}} ,.

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):

MemberKindTypeDescription
valueinput/output (model)stringTwo-way bound markup string, e.g. [(value)]="body".
triggersinput (required)IMentionTriggerConfig[]The list of triggers the editor listens for.
placeholderinputstringPlaceholder text shown when empty.
disabledinputbooleanDisables editing and closes any open dropdown.
multilineinputbooleanWhen false, Enter emits submitted instead of inserting a line break.
segmentssignal (readonly)MentionSegment[]value parsed into text and mention segments.
plainTextsignal (readonly)stringvalue rendered as human-readable text (pills as their display label).
mentionAddedoutputIMentionSegmentEmitted when a pill is inserted, by picking or auto-converting a suffix trigger.
mentionRemovedoutputIMentionSegmentEmitted when a pill is deleted.
submittedoutputvoidEmitted on Enter when multiline is false.
insertMention(item)method(item: IMentionItem) => voidProgrammatically 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:

VariableFallback
--ngx-mentions-editor-colorinherit
--ngx-mentions-editor-bg#fff
--ngx-mentions-editor-border#d1d5db
--ngx-mentions-editor-radius6px
--ngx-mentions-editor-padding0.5em 0.75em
--ngx-mentions-editor-min-height2.5em
--ngx-mentions-focus-border#6366f1
--ngx-mentions-focus-ring-width2px
--ngx-mentions-focus-ring-colorrgba(99, 102, 241, 0.25)
--ngx-mentions-placeholder-color#9ca3af
--ngx-mentions-disabled-opacity0.6
--ngx-mentions-editor-disabled-bg#f3f4f6
--ngx-mentions-pill-padding0 0.25em
--ngx-mentions-pill-radius4px
--ngx-mentions-pill-bg#e0e7ff
--ngx-mentions-pill-color#3730a3
--ngx-mentions-dropdown-z1000
--ngx-mentions-dropdown-min-width180px
--ngx-mentions-dropdown-max-width320px
--ngx-mentions-dropdown-max-height240px
--ngx-mentions-dropdown-bg#fff
--ngx-mentions-dropdown-border#e5e7eb
--ngx-mentions-dropdown-radius8px
--ngx-mentions-dropdown-shadow0 4px 12px rgba(0, 0, 0, 0.1)
--ngx-mentions-item-padding6px 10px
--ngx-mentions-item-active-bg#eef2ff
--ngx-mentions-loading-gap4px
--ngx-mentions-loading-color#9ca3af
--ngx-mentions-loading-dot-size5px
.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, item id values should avoid ).
  • With a suffix-based format (e.g. {{ }}), item id values should match [\w.-]+.
  • Two trigger configs must not share the same trigger string.
  • Undo/redo across a programmatically inserted pill (via insertMention or 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-mentions

To publish a new version:

yarn bv             # bump patch version in root and library package.json
yarn build:lib
yarn publish:lib

License

MIT