@danielivanovz/mention

API Reference

Props, types, and escape-hatch hook for @danielivanovz/mention. Source-of-truth is packages/react/src/types.ts.

The compound API (Mention.Root + sub-components) is the 80% case. useMention<TItem>() is the escape hatch when you need to drive the ARIA contract yourself.

<Mention.Root>

Wraps an <Input> + <Popover> and orchestrates the state machine. Generic over your item type TItem.

PropTypeDefaultNotes
itemsreadonly TItem[] | (query: string, signal: AbortSignal) => Promise<readonly TItem[]>requiredSync array or async fetcher. The fetcher receives an AbortSignal — wire it to fetch for free cancellation on rekey.
getKey(item: TItem) => string | numberrequiredStable, distinct per item. Used as React key and for memoisation.
getLabel(item: TItem) => stringrequiredHuman-readable label; default insertion text is `${trigger}${getLabel(item)}` plus a trailing space.
onSelect(item: TItem, meta: MentionSelectMeta) => voidrequiredNotifier — fires on Enter / Tab / click. Doesn't control insertion (use getInsertText).
triggerstring"@"Trigger character — single char. "@" for mentions, "/" for slash-commands, "#" for channels, ":" for emoji shortcodes. Mid-word triggers (e.g. foo@bar, /docs/api) are suppressed by the isolation rule. For more than one trigger character in the same editor, see the multi-trigger overload.
getInsertText(item: TItem, meta: MentionSelectMeta) => string`${trigger}${getLabel(item)}`Override the inserted text. Useful for markdown links or chip syntax.
debounceMsnumber150Async-fetcher debounce. Pass 0 to disable.
unstyledbooleanfalseSkip default popover CSS. Bring your own design system.
handleRefRefObject<MentionImperativeHandle<TItem> | null>Imperative handle — open, close, commit, textarea.
childrenReactNoderequiredCompose <Mention.Input> + <Mention.Popover> inside.

MentionSelectMeta — passed to onSelect and getInsertText:

FieldTypeNotes
triggerstringTrigger character that opened the menu.
querystringSubstring the user typed between @ and the caret.
triggerOffsetnumberCaret offset where the trigger sits.

<Mention.Root> — multi-trigger

<Mention.Root> is overloaded. The single-trigger props above are one form; the multi-trigger form below lets one editor host arbitrary trigger characters with independent per-channel config. Pick the form that matches your data: pass triggers to take this path, otherwise pass items for single-trigger.

function Root<TItemMap extends Record<string, unknown>>(
  props: MentionRootMultiProps<TItemMap>,
): React.ReactNode;
PropTypeDefaultNotes
triggers{ [K in keyof TItemMap]: MentionChannelConfig<TItemMap[K]> }requiredPer-channel config keyed by trigger char. Keys are your trigger chars (any single character). Values carry items, getKey, getLabel, optional getInsertText.
onSelect(payload: { [K in keyof TItemMap]: { [P in K]: TItemMap[K] } }[keyof TItemMap], meta) => voidrequiredDiscriminated-union payload — narrow with '@' in payload (or whatever key you used).
debounceMsnumber150Async-fetcher debounce, shared across channels.
unstyledbooleanfalseSkip default popover CSS.
handleRefRefObject<MentionImperativeHandle<TItemMap[keyof TItemMap]> | null>Imperative handle. The commit(item) method accepts an item from any channel.
childrenReactNoderequiredCompose <Mention.Input> + <Mention.Popover> inside.

MentionChannelConfig<TItem> — one entry per trigger char in triggers:

FieldTypeDefaultNotes
itemsreadonly TItem[] | (query, signal) => Promise<readonly TItem[]>requiredSync or async, same shape as the single-trigger form.
getKey(item: TItem) => string | numberrequiredStable key per item.
getLabel(item: TItem) => stringrequiredHuman-readable label.
getInsertText(item: TItem, meta) => string`${trigger}${getLabel(item)}`Override the inserted text per channel.

The full recipe with worked examples (slash commands, mentions, emoji shortcodes in one editor) is in recipes/multi-trigger.

<Mention.Input>

Forwards to the underlying <textarea>. Accepts every standard TextareaHTMLAttributes prop except the ones the library owns (the ARIA contract) or composes (onChange, onKeyDown).

PropTypeNotes
refRef<HTMLTextAreaElement>Forwarded to the textarea. Useful for focus management.

The library wires (don't override): role="combobox", aria-controls, aria-expanded, aria-haspopup, aria-autocomplete="list", aria-activedescendant, plus key handlers for Arrow keys / Enter / Tab / Escape / trigger detection.

<Mention.Popover>

Container for the listbox + Empty + Loading branches. Portals to document.body by default.

PropTypeDefaultNotes
containerHTMLElement | nulldocument.bodyPortal target. Pass null to render in-place — useful when TalkBack swipe order breaks with portals.
maxHeightnumber280Max popover height in px before it scrolls.
aria-labelstringAccessible name for the listbox. APG requires either this or aria-labelledby.
aria-labelledbystringId of a visible label element.
childrenReactNoderequiredTypically <Mention.List> + <Mention.Empty> + <Mention.Loading>.

<Mention.List<TItem>>

Render-prop body. The function is called per item with (item, index).

<Mention.List>
  {(user) => <Mention.Item value={user}>{user.name}</Mention.Item>}
</Mention.List>
PropTypeDefaultNotes
triggerstringMulti-trigger only. When set, this list only renders while the popover's active trigger character matches. Lets you compose multiple type-safe lists, one per channel: <Mention.List<Command> trigger="/">{(cmd) => …}</Mention.List>. Omit for single-trigger.
children(item: TItem, index: number) => ReactNoderequiredRender-prop. Must return a <Mention.Item>.

<Mention.List> applies React keys via getKey(item) automatically — escape-hatch consumers using getItemProps directly must pass key={getKey(item)} themselves (React 19 strict-mode warns on key-via-spread, so it's intentionally not in getItemProps).

<Mention.Item>

PropTypeNotes
valueTItemRequired. The item this option represents.
childrenReactNodeVisual content — typically the label, optionally with avatar / metadata.

<Mention.Empty> / <Mention.Loading>

Each accepts children: ReactNode. <Mention.Empty> renders when the filtered/fetched list is empty; <Mention.Loading> renders while an async fetcher is pending.

MentionImperativeHandle<TItem>

Returned via handleRef:

MethodNotes
open()Open the popover programmatically.
close()Close the popover programmatically.
commit(item)Same effect as the user pressing Enter on item.
textareaDirect access for focus management.

useMention<TItem>(props) — escape hatch

When the compound API doesn't fit (custom layouts, non-listbox UI, splitting input + popover across the tree), drive the ARIA contract yourself.

Props. Same shape as MentionRootProps, plus:

PropTypeNotes
valuestringOptional controlled textarea value.
onValueChange(value: string) => voidFires on every change — both user input and committed selections.

Returns (MentionContext<TItem>):

FieldTypeNotes
querystringSubstring after the trigger up to the caret.
openbooleanWhether the popover is open.
highlightedIndexnumber-1 if none.
itemsreadonly TItem[]Filtered / fetched items currently displayed.
status"idle" | "loading" | "error" | "success"For loading / error UI.
getInputProps()Record<string, unknown>Spread on the textarea.
getPopoverProps()Record<string, unknown>Spread on the popover container.
getItemProps(item, index)Record<string, unknown>Spread on each option. Does not include key — pass it explicitly.
setOpen(open)(open: boolean) => voidImperative open/close.
commit(item)(item: TItem) => voidImperative commit.

See Recipes → Custom rendering for an end-to-end example.

useMentionMulti<TItemMap>(props) — multi-trigger escape hatch

The typed multi-trigger counterpart to useMention<TItem>(). Use when you want multi-channel routing (e.g., / for commands + @ for people) without the compound API — command palettes, inline suggestions, fully custom popovers.

Props. Same shape as MentionRootMultiProps, plus the controlled-input fields the hook surfaces for headless integration:

PropTypeNotes
triggers{ [K in keyof TItemMap]: MentionChannelConfig<TItemMap[K]> }Per-channel config keyed by trigger character.
onSelect(payload, meta) => voidDiscriminated-union payload { [activeTrigger]: TItemMap[activeTrigger] }.
debounceMsnumberAsync-fetcher debounce, shared across channels.
valuestringOptional controlled textarea value.
onValueChange(value: string) => voidFires on every change.

Returns (MentionMultiContext<TItemMap>):

FieldTypeNotes
activeTriggerkeyof TItemMap | nullWhich trigger character opened the popover. null when closed. Use to narrow items / commit per channel.
itemsreadonly TItemMap[keyof TItemMap][]Items from the active channel only. Cast to the appropriate type after narrowing on activeTrigger.
query, open, highlightedIndex, status, getInputProps(), getPopoverProps(), getItemProps(), setOpen(), commit()Same as useMention.

See Recipes → Multi-trigger for a worked command-palette example.

findActiveMention(value, caret, trigger) — low-level utility

The dispatcher's backwards-scan helper, exposed for hand-rolled dispatchers and headless integrations that don't use any of the hooks or the compound API. Same isolation rules + Unicode word-boundary handling that the rest of the library uses.

import { findActiveMention, type ActiveMention } from "@danielivanovz/mention";

const result: ActiveMention | null = findActiveMention(
  textarea.value,
  textarea.selectionStart,
  ["/", "@", ":"],   // string | readonly string[]
);
// → { trigger: "@", query: "ali" }   if matched
// → null                              if not
ParamTypeNotes
valuestringThe textarea (or contenteditable) text content.
caretnumberCaret offset.
triggerstring | readonly string[]Single char or array. Default "@".

Returns { trigger: string; query: string } \| null. trigger reports which configured char actually matched (useful when scanning for multiple triggers); query is the substring between the trigger and the caret.

On this page