Mention
Accessible, unstyled trigger-character mentions for textareas, inputs, and contenteditable.
|
|
| Package |
@vizejs/ui/mention |
| Maturity |
stable |
| Own the source |
vize lib pull mention |
| Requires |
collection, composite-navigation, context, controllable-state, dismissable-layer, id, portal, positioner, presence |
| Aliases |
mention, mentions, at mention, trigger autocomplete, hashtag input |
| Covers |
WAI-ARIA combobox pattern (listbox popup), Ant Design Mentions, Mantine Mentions, Tiptap Suggestion |
Minimal setup
Use a Vue 3.5+ project with Vue SFC compilation configured (see Vite+ integration).
vp install @vizejs/ui
Add the public import below to <script setup lang="ts">. Components provide behavior without styles; the preview uses optional Paper styles.
import { Mention, MentionRoot, MentionContent, MentionEditable, MentionEmpty, MentionInput, MentionItem } from "@vizejs/ui/mention";
Or copy the source into your project with vize lib pull mention (see Source Distribution).
Try it
This is the basic example maintained with the component source, using the public package imports. The preview adds the optional Paper styles.
Open the interactive example · Styling these components
Copy the example
<!-- Comment textarea that suggests teammates after typing "@" and inserts the chosen handle. -->
<script setup lang="ts">
import { ref, useId } from "vue";
import { Mention, MentionContent, MentionEmpty, MentionInput, MentionItem } from "@vizejs/ui/mention";
interface Teammate {
readonly handle: string;
readonly name: string;
}
const teammates: readonly Teammate[] = [
{ handle: "ada", name: "Ada Lovelace" },
{ handle: "alan", name: "Alan Turing" },
{ handle: "grace", name: "Grace Hopper" },
];
const comment = ref("Thanks for the review, ");
const labelId = useId();
</script>
<template>
<div>
<span :id="labelId">Comment</span>
<Mention
v-slot="{ filteredItems }"
v-model="comment"
:items="teammates"
:item-text="(teammate: Teammate) => teammate.handle"
>
<MentionInput name="comment" :rows="3" :aria-labelledby="labelId" />
<MentionContent aria-label="Teammates">
<MentionItem v-for="teammate in filteredItems" :key="teammate.handle" :value="teammate">
{{ teammate.name }} (@{{ teammate.handle }})
</MentionItem>
<MentionEmpty>No teammates match</MentionEmpty>
</MentionContent>
</Mention>
</div>
</template>
API
Mention<T>
Source: src/families/form/mention/mention-root.vue
Props
| Prop |
Type |
Default |
Description |
id |
string | null |
undefined |
Consumer-owned base id. null and undefined select a deterministic fallback. |
modelValue |
string |
undefined |
Controlled field text for MentionInput. undefined selects uncontrolled behavior. |
defaultValue |
string |
"" |
Initial field text for uncontrolled use. |
items |
readonly T[] |
undefined |
Candidate items filtered by the active query and exposed as filteredItems. |
loadItems |
MentionLoader<T> |
undefined |
Async item source; its results replace items. |
debounce |
number |
150 |
Debounce in milliseconds before loadItems runs for a changed query. |
triggers |
readonly MentionTrigger[] |
[{ char: "@" }] |
Trigger characters and their query rules. |
itemText |
(item: T) => string |
undefined |
Human-readable text for an item, used by filtering and the default insertion. |
filter |
MentionFilter<T> | false |
undefined |
Filter for items; false keeps every item (server-side filtering). |
insert |
MentionInsertTransform<T> |
undefined |
Text inserted for a chosen item. Defaults to trigger + item text + one space. |
open |
boolean |
undefined |
Controlled popup open state. The popup only shows while a trigger token is active. |
defaultOpen |
boolean |
false |
Initial open state for uncontrolled use. |
loop |
boolean |
false |
Wrap arrow-key navigation at the first and last item. |
disabled |
boolean |
false |
Disable trigger detection and editing. |
Events
| Event |
Payload |
Description |
update:modelValue |
[text: string] |
Fired when the field text requests a new controlled value. |
select |
[item: T, trigger: MentionTrigger] |
Fired after an item is inserted for the active token, with the item and its trigger. |
update:query |
[query: string] |
Fired when the active query changes; "" when no token is active. |
query-change |
[query: string | null, trigger: MentionTrigger | null] |
Fired when the active token changes, with its query and trigger or null for both when it ends. |
update:open |
[open: boolean] |
Fired when the popup requests a controlled open value. |
open-change |
[open: boolean, previous: boolean, nativeEvent: Event | null] |
Fired after any distinct open-state request with next state, previous state, and event. |
Slots
| Slot |
Slot props |
Description |
default |
MentionSlotState<T> |
The field (MentionInput or MentionEditable) and MentionContent. Receives query and item state. |
Exposed
| Member |
Type |
Description |
dismiss |
— |
|
focus |
— |
|
match |
— |
|
open |
— |
|
refresh |
— |
|
select |
— |
|
text |
— |
|
MentionRoot<T>
Source: src/families/form/mention/mention-root.vue
Props
| Prop |
Type |
Default |
Description |
id |
string | null |
undefined |
Consumer-owned base id. null and undefined select a deterministic fallback. |
modelValue |
string |
undefined |
Controlled field text for MentionInput. undefined selects uncontrolled behavior. |
defaultValue |
string |
"" |
Initial field text for uncontrolled use. |
items |
readonly T[] |
undefined |
Candidate items filtered by the active query and exposed as filteredItems. |
loadItems |
MentionLoader<T> |
undefined |
Async item source; its results replace items. |
debounce |
number |
150 |
Debounce in milliseconds before loadItems runs for a changed query. |
triggers |
readonly MentionTrigger[] |
[{ char: "@" }] |
Trigger characters and their query rules. |
itemText |
(item: T) => string |
undefined |
Human-readable text for an item, used by filtering and the default insertion. |
filter |
MentionFilter<T> | false |
undefined |
Filter for items; false keeps every item (server-side filtering). |
insert |
MentionInsertTransform<T> |
undefined |
Text inserted for a chosen item. Defaults to trigger + item text + one space. |
open |
boolean |
undefined |
Controlled popup open state. The popup only shows while a trigger token is active. |
defaultOpen |
boolean |
false |
Initial open state for uncontrolled use. |
loop |
boolean |
false |
Wrap arrow-key navigation at the first and last item. |
disabled |
boolean |
false |
Disable trigger detection and editing. |
Events
| Event |
Payload |
Description |
update:modelValue |
[text: string] |
Fired when the field text requests a new controlled value. |
select |
[item: T, trigger: MentionTrigger] |
Fired after an item is inserted for the active token, with the item and its trigger. |
update:query |
[query: string] |
Fired when the active query changes; "" when no token is active. |
query-change |
[query: string | null, trigger: MentionTrigger | null] |
Fired when the active token changes, with its query and trigger or null for both when it ends. |
update:open |
[open: boolean] |
Fired when the popup requests a controlled open value. |
open-change |
[open: boolean, previous: boolean, nativeEvent: Event | null] |
Fired after any distinct open-state request with next state, previous state, and event. |
Slots
| Slot |
Slot props |
Description |
default |
MentionSlotState<T> |
The field (MentionInput or MentionEditable) and MentionContent. Receives query and item state. |
Exposed
| Member |
Type |
Description |
text |
string |
Current field text. |
match |
MentionMatch | null |
Active token, or null. |
open |
boolean |
Whether the popup is open. |
select |
(item: T) => boolean |
Insert item for the active token and report whether text changed. |
dismiss |
() => void |
Close the popup until the caret enters a different token. |
refresh |
() => void |
Re-read text and caret from the field and re-detect the token. |
focus |
(options?: FocusOptions) => void |
Focus the field. |
MentionContent
Source: src/families/form/mention/mention-content.vue
Props
| Prop |
Type |
Default |
Description |
placement |
Placement |
"bottom-start" |
Preferred placement relative to the trigger character before collision handling. |
strategy |
PositionerStrategy |
"fixed" |
CSS positioning mode published on the floating host. |
offset |
number |
4 |
Gap between the caret line and the popup. |
collisionPadding |
number |
8 |
Viewport padding the popup should not cross. |
flip |
boolean |
true |
Flip to the opposite side when the preferred side overflows more. |
shift |
boolean |
true |
Shift the popup back into the viewport. |
size |
boolean |
true |
Publish available-size CSS variables and constrain the host. |
to |
string | HTMLElement |
"body" |
CSS selector or element the popup is moved into. |
portalDisabled |
boolean |
false |
Render in place instead of teleporting. |
defer |
boolean |
true |
Keep content in place until the portal target exists, avoiding SSR mismatch. |
forceMount |
boolean |
false |
Keep items mounted (hidden) while closed. |
ariaLabel |
string |
undefined |
Accessible name of the suggestion listbox. |
Events
| Event |
Payload |
Description |
escape-key-down |
[event: DismissableLayerEscapeKeyDownEvent] |
Fired before Escape closes the popup. Call preventDefault() to keep it open. |
pointer-down-outside |
[event: DismissableLayerPointerDownOutsideEvent] |
Fired before an outside pointer-down closes the popup. Call preventDefault() to keep it open. |
dismiss |
[event: DismissableLayerDismissEvent] |
Fired after an unprevented dismissal request closes the popup. |
Slots
| Slot |
Slot props |
Description |
default |
MentionContentSlotState |
MentionItems and MentionEmpty. Receives open state, placement, and the query. |
Exposed
| Member |
Type |
Description |
element |
Readonly<ShallowRef<HTMLElement | null>> |
Template ref to the rendered root element. |
MentionEditable
Source: src/families/form/mention/mention-editable.vue
Props
| Prop |
Type |
Default |
Description |
ariaLabel |
string |
undefined |
Accessible name when no visible label supplies one. |
ariaLabelledby |
string |
undefined |
Space-separated ids of visible labels. |
ariaDescribedby |
string |
undefined |
Space-separated ids that describe the field. |
multiline |
boolean |
true |
Announce the editor as multi-line. |
Slots
| Slot |
Slot props |
Description |
default |
— |
Initial editor content rendered on the server and adopted on hydration. |
Exposed
| Member |
Type |
Description |
element |
Readonly<ShallowRef<HTMLElement | null>> |
Template ref to the rendered root element. |
focus |
— |
|
MentionEmpty
Source: src/families/form/mention/mention-empty.vue
Slots
| Slot |
Slot props |
Description |
default |
{ readonly query: string } |
Message shown when no suggestion matches and nothing is loading. Receives the query. |
Source: src/families/form/mention/mention-input.vue
Props
| Prop |
Type |
Default |
Description |
as |
"input" | "textarea" |
"textarea" |
Native text control to render. |
name |
string |
undefined |
Form control name; the field submits its plain text natively. |
placeholder |
string |
undefined |
Hint text shown while empty. |
rows |
number |
undefined |
Visible text lines for the textarea. |
ariaLabel |
string |
undefined |
Accessible name when no visible label supplies one. |
ariaLabelledby |
string |
undefined |
Space-separated ids of visible labels. |
ariaDescribedby |
string |
undefined |
Space-separated ids that describe the field. |
Exposed
| Member |
Type |
Description |
element |
Readonly<ShallowRef<HTMLElement | null>> |
Template ref to the rendered root element. |
focus |
— |
|
MentionItem<T>
Source: src/families/form/mention/mention-item.vue
Props
| Prop |
Type |
Default |
Description |
id |
string | null |
undefined |
Consumer-owned option id. null and undefined select a deterministic fallback. |
value (required) |
T |
required |
Item inserted when this option is chosen. |
disabled |
boolean |
false |
Disable this option. |
textValue |
string |
undefined |
Explicit option text. undefined extracts the rendered text. |
Slots
| Slot |
Slot props |
Description |
default |
MentionItemSlotState<T> |
Option content. Receives the item, highlight, and disabled state. |
Exposed
| Member |
Type |
Description |
active |
— |
|
element |
Readonly<ShallowRef<HTMLElement | null>> |
Template ref to the rendered root element. |
select |
— |
|
Behavior
Contract
Mention is a headless trigger-character helper (@ people, # tags, :
emoji, …) for native text fields and contenteditable editors.
mention-core.ts is DOM-free: detectMention(text, caret, triggers) finds the
active token and applyMentionEdit replaces it with the text produced by an
insertion transform. mention-root.vue is generic over the item type T,
inferred from items or loadItems, and owns the text model, the active
token, filtering, the async loader, and an active-descendant collection built
on the collection registry and composite navigation.
mention-input.vue renders a native <textarea> (or <input role="combobox">
with as="input") and mention-editable.vue a role="textbox"
contenteditable. Both keep DOM focus and expose the highlighted option through
aria-activedescendant, aria-autocomplete="list", and aria-controls.
mention-content.vue renders the role="listbox" popup through Portal,
Presence, and a Positioner anchored to a virtual element at the trigger
character (a mirror element for text fields, a DOM Range for contenteditable),
with a dismissable layer for outside presses and Escape. mention-item.vue
renders role="option" and mention-empty.vue the no-results message. No CSS
is shipped.
Public Surface
| Surface |
Contract |
MentionRoot props |
id, modelValue, defaultValue, items, loadItems, debounce, triggers, itemText, filter, insert, open, defaultOpen, loop, disabled |
MentionRoot emits |
update:modelValue(text), select(item, trigger), update:query(query), query-change(query, trigger), update:open(open), open-change(open, previous, e) |
MentionRoot slot |
text, query, trigger, match, filteredItems, open, loading, status, error, state |
MentionRoot expose |
text, match, open, select, dismiss, refresh, focus |
MentionInput props |
as, name, placeholder, rows, ariaLabel, ariaLabelledby, ariaDescribedby |
MentionContent |
positioner props, forceMount, to, portalDisabled, defer, ariaLabel; emits escape-key-down, pointer-down-outside, dismiss |
| Trigger rules |
start of text or after whitespace/punctuation; @@ escapes; query ends at whitespace unless allowSpaces, always at a newline; pattern, minChars, maxChars |
Normative Behavior
| # |
State |
Input |
Outcome |
Proven by |
| M1 |
idle |
render |
textarea with aria-autocomplete="list", aria-haspopup="listbox", no aria-controls, no popup |
renders a textarea with listbox autocomplete semantics and no popup |
| M2 |
idle |
type a trigger + query |
opens, wires aria-controls, filters by query, highlights the first item, emits update:query/query-change; empty content shows |
typing a trigger opens the listbox, filters by query, and highlights the first item |
| M3 |
any |
mid-word trigger / caret leaves token |
never opens mid-word; moving the caret out of the token closes and emits query-change(null, null) |
mid-word triggers never open and moving the caret out of the token closes |
| M4 |
open |
ArrowDown/ArrowUp/Home/End/Enter/Tab/Escape |
arrows and Home/End move the highlight (loop wraps); Enter and Tab insert, move the caret, emit select; Escape dismisses until a new token |
keyboard matrix: arrows, Home/End, Enter inserts, Tab inserts, Escape dismisses |
| M5 |
closed / no match |
ArrowDown / Enter |
not consumed, so native editing and form submission proceed |
keys pass through while closed and Enter without a highlight is not consumed |
| M6 |
open |
pointer move / press / click |
highlights, keeps focus in the field, inserts and merges a doubled space |
clicking an item inserts it and pointer movement highlights |
| M7 |
several triggers |
# / @ tokens with insert |
each token uses its trigger; the transform receives item and trigger; as="input" renders role="combobox" with aria-expanded |
insertion transforms and multiple triggers receive the item and trigger |
| M8 |
contenteditable |
type / Enter |
detects the token from the DOM selection and replaces it in place, emitting the new text |
contenteditable fields detect tokens from the selection and insert text in place |
| M9 |
loadItems |
type / newer query / close |
loads per query and trigger, publishes loading, hides empty content meanwhile, aborts superseded requests, keeps loaded order |
loadItems loads per query, publishes loading, and aborts superseded requests |
| M10 |
controlled / API |
filter / dismiss / disabled |
injected filters replace the default; dismiss() closes; disabled fields never open |
controlled text, filter injection, disabled state, and exposed methods |
| M11 |
no provider |
mount a part |
throws VIZE_UI_CONTEXT_MISSING |
parts require a Mention provider |
| M12 |
caret helpers |
measure / locate / replace |
mirror measurement leaves no nodes behind; offsets map across text nodes; editable replacement edits in place |
caret helpers measure text fields and editable content without leaking mirrors |
| C1 |
core |
detect |
start and after punctuation; caret-based |
detects a trigger at the start and after whitespace or punctuation, uses the caret position, not the end of the text |
| C2 |
core |
reject |
mid-word, @@, whitespace, newline, out-of-range caret |
never triggers mid-word, after a repeated trigger, or across whitespace and newlines |
| C3 |
core |
trigger options |
pattern, minChars, allowSpaces, maxChars, multi-character triggers |
supports several triggers with patterns, minimum length, and spaces, multi-character triggers match as a unit |
| C4 |
core |
edit / compare / filter |
default insertion, caret after insertion, doubled-space merge, match equality, accent-insensitive filter |
applies edits with the default insertion and collapses a doubled space, compares matches and normalizes filter text |
SSR
No token is active on the server, so the popup renders closed and every id
comes from the deterministic-id primitive. Textarea and contenteditable trees
render byte-identical markup and hydrate without warnings (renders byte-identical Mention textarea markup across isolated requests, renders byte-identical contenteditable markup, hydrates textarea and contenteditable Mentions without mismatches). Caret measurement only runs in handlers and
positioner updates. For mention-editable.vue the editor DOM is the source of
truth: its text is read on input and written back on insertion.