# Accessibility Interaction behavior, automated checks, and manual verification limits. Canonical: https://reactmention.com/docs/accessibility The editor keeps DOM focus while suggestions are navigated. The popup uses `role="listbox"`; options use `role="option"` and `aria-selected`. The host's `aria-activedescendant` points to the highlighted option, and `aria-controls` points to the mounted listbox. Name the input with a label or `aria-label`, and give the listbox an accessible name through `Mention.Popover`. For the implementation details behind these relationships, see [Focus and ARIA](/docs/internals/interaction). ## Keyboard behavior [#keyboard-behavior] | Key | Behavior while suggestions are open | | ------------------- | ---------------------------------------------------------- | | ArrowDown / ArrowUp | Move the highlight and reveal it in the scrolling list. | | Enter / Tab | Commit the current result if the selection is unchanged. | | Escape | Dismiss without editing the text. | | IME confirmation | Leave composition to the host; do not commit a suggestion. | Blur and outside pointer interaction dismiss the list. Consumer key handlers can prevent Mention from handling a key. ## Preserve native text semantics [#preserve-native-text-semantics] `Mention.Input` remains a native textarea with its implicit `textbox` role. Mention supplies suggestion relationships without assigning `role` or `aria-expanded` to the host. The [HTML-ARIA textarea entry](https://www.w3.org/TR/html-aria/#el-textarea) permits the native textbox semantics. [WAI-ARIA permits a textbox](https://www.w3.org/TR/wai-aria-1.2/#aria-activedescendant) to reference an active option inside a listbox identified by `aria-controls`. For a rich editor, provide its accessible name, `role="textbox"`, and `aria-multiline="true"` on the editable host, then apply `getEditorProps()` as the suggestion state changes. See the complete [ProseMirror example](/docs/rich-text). ## Verification [#verification] Browser tests check focus, keyboard behavior, attribute relationships, and automated accessibility rules. The idle textareas and the short lists in the executable composer and form examples pass unfiltered axe checks. The textarea role needs no rule exception. Two findings are recorded explicitly in the larger test fixture: * `scrollable-region-focusable`: axe's heuristic exempts combobox popups, but does not recognize this textbox-controlled list. Mention keeps focus in the textarea; arrow keys select and scroll options into view. Browser tests verify access beyond the scroll boundary. Do not add a second tab stop merely to silence the rule; verify keyboard access in your actual host. * `region`: a listbox portaled to the body may sit outside a landmark. Use `container={null}` or an appropriate landmark container for your application's layout and reading order. The original prototype had user-reported assistive-technology testing. That result does not validate the current implementation or this structural revision. The current library still needs manual checks with NVDA, JAWS, VoiceOver, and TalkBack, plus real OS IMEs. Synthetic composition tests cover event handling, not real candidate-window behavior. Test with the actual application, including dialogs, portals, scrolling containers, input labels, and custom option content. For TalkBack swipe order, an in-place popup is available through `container={null}`. Default styles include forced-colors rules. Custom styling must preserve a visible highlighted state. --- # Coding-agent guide Integrate Mention with a typed example, clear ownership, and checks you can verify. Canonical: https://reactmention.com/docs/agents Use this guide as the starting context for a coding agent working in an existing application. Read the [quickstart](/docs), then open only the recipes needed for the task. The [documentation index](/llms.txt) links directly to Markdown; [the full export](/llms-full.txt) is available when broader context is useful. Choose **Agent setup** above, review the prompt, then select **Copy prompt** and paste it into a coding agent with access to your project. The prompt asks the agent to inspect your app, read the relevant documentation, make a focused change, and verify it. On recipe pages, it includes that recipe's direct Markdown link. You can edit the request after pasting to specify your input, trigger, or data source. ## Establish the host [#establish-the-host] Mention is a headless **React 19** library for suggestions at the caret. These examples require Mention 0.2.1: install `@danielivanov/mention@^0.2.1` with the application's package manager. Import `@danielivanov/mention/styles.css` once if you want the default popup styles. * **Positioning:** `Mention.Popover` anchors to the caret by default. For a panel above the whole composer, pass its wrapper ref as `anchorRef`, set `placement="top-start"`, and enable `matchAnchorWidth`. `container` selects the portal destination independently. * **Plain text:** use `Mention.Root`, `Mention.Input`, `Mention.Popover`, `Mention.List`, and `Mention.Item`. The input is a native textarea. Preserve the application's existing `value`, `onChange`, form attributes, and refs. * **Structured editor content:** implement `EditorAdapter`. The editor owns its document, mention nodes, selection model, transactions, clipboard behavior, and undo. Mention owns detection, suggestions, highlight, and selection. Never replace the rich editor's DOM or flatten its document into textarea state. ## Implement the smallest integration [#implement-the-smallest-integration] Use the complete [typed quickstart](/docs#add-a-composer) as the reference. Declare an item type with a stable key and a search label; pass `getKey` and `getLabel` to Root. Write `Mention.List` explicitly. React context cannot infer a list's type from Root, or verify that a multi-trigger list matches its channel. In Next.js App Router, put `"use client"` at the top of the component containing state, event handlers, or the item render function. The package's client boundary does not make function props created by a server component serializable. `onSelect` is an optional notification after insertion; it is not required to insert a mention and does not track which mentions remain in the document. A textarea inserts text, not persistent mention entities. Do not add placeholder callbacks or duplicate the input's value in a separate mention state. Arrays are filtered locally by case-insensitive label substring by default. Use a pure synchronous `filter(item, query)` for application-specific matching, and opt into `allowSpaces` per channel for full-name queries. Fetcher results bypass `filter`. An async fetcher has type `(query: string, signal: AbortSignal) => Promise`; it supplies its own search results. Forward `signal` to network calls and keep the fetcher reference stable. The default debounce is 150 ms. The core also rejects obsolete results when a fetcher ignores cancellation. For an editor adapter, register with `setEditor(adapter)`, clear it on cleanup, call `refresh()` after document or selection transactions, and forward keyboard events through `handleKeyDown` before editor bindings. Use consistent UTF-16 offsets within one text region. See [editor integration](/docs/rich-text) and the [adapter contract](/docs/api-reference#editor-adapter). ## Open the relevant reference [#open-the-relevant-reference] * [Controlled forms](/docs/recipes/controlled-value) for form state and event composition. * [Async search](/docs/recipes/async-items) for request and error handling. * [Lexical](/docs/lexical) for token nodes, clipboard identity, and editor-owned history. * [AI composer](/docs/ai-composer) for shadcn/ui, AI SDK 7, current-document reference submission, authenticated model-context conversion, and GitHub registry installation. The demo uses helpers; installed application code does not. * [Name matching](/docs/recipes/i18n#name-matching) for full names and synchronous accent folding. * [Multiple triggers](/docs/recipes/multi-trigger) for typed channels. * [Custom rendering](/docs/recipes/custom-rendering) and [styling](/docs/recipes/styling) for application-owned UI. * [API reference](/docs/api-reference) for public types and props. * [Troubleshooting](/docs/troubleshooting) for detection, registration, and positioning failures. ## Verify the integration [#verify-the-integration] Run the application's type check and relevant tests. Then exercise the real host: 1. Type `@al` at a valid boundary. Navigate with arrow keys, insert with Enter or Tab, and verify both the displayed text and form state. Escape should dismiss without editing; focus should stay in the host. 2. Test no matches, blur, pointer selection, and moving the caret before insertion. Old results must not insert into a changed selection. 3. For async search, resolve an older request after a newer one. Confirm that only current results appear and can be selected. Verify loading, empty, and failed requests separately. 4. For rich editors, verify mention serialization, paragraph boundaries, formatting, paste, undo, redo, and node deletion in that editor's document model. 5. Check names, visible focus/highlight, dialogs, portals, scrolling, and composition. Follow the [accessibility verification limits](/docs/accessibility#verification). Report the commands run, the interaction outcomes observed, and any checks left unperformed. The executable ProseMirror and Lexical examples establish their tested integrations; other adapters require their own verification. Preserve the textarea's native textbox semantics. A rich editor supplies its own textbox role and multiline state; Mention supplies suggestion relationships. Automated browser tests do not prove assistive-technology compatibility or real OS IME behavior. --- # AI composer Add document references to a shadcn/ui composer with AI SDK 7 and Lexical. Canonical: https://reactmention.com/docs/ai-composer Write “Compare @pricing.md with @roadmap.md” and send the selected document IDs with your message. Mention handles suggestions, Lexical stores the draft and references, shadcn/ui supplies the controls, and AI SDK sends and receives messages. ## Try the interaction [#try-the-interaction] Type `@`, choose a document, then press Enter again to send. Delete a reference or undo its deletion before sending to see how the request changes. **View submitted context** shows the exact outgoing message parts. Responses are scripted in your browser. Enable **Fail the next response** to try recovery. Stopping or failing a response keeps your draft.
## Install the composer [#install-the-composer] In a React 19 project configured for shadcn/ui: ```sh title="Install from the Mention GitHub registry" npx shadcn@latest add danielivanovz/mention/ai-composer ``` The item installs Mention 0.2.1, AI SDK 7, the matching React hooks, Lexical 0.50, and the required shadcn components. It copies four files into your components directory: the composer, editor host, message types, and server resolver. Preview the files with `shadcn view danielivanovz/mention/ai-composer` or add `--dry-run` to the install command. Append `#` to the item address to pin a GitHub revision. Render the copied composer with the documents the current user may search: ```tsx title="DocumentChat.tsx" "use client"; import { AiComposer } from "@/components/ai-composer/ai-composer"; export function DocumentChat() { return ; } ``` Configure your application's authenticated `/api/chat` route before sending. The composer posts there by default. Pass a `ChatTransport` as `transport` to use another endpoint or transport. ## Turn references into model context [#turn-references-into-model-context] The user message contains a text part and, when needed, a typed `data-mentions` part with `{ id, name }` references. Both come from one read of the current Lexical document. Repeated references to the same ID are sent once; typing a matching label as plain text does not create a reference. `onSelect` is an insertion notification, not the current reference inventory. Never accumulate a second list there: it would include mentions deleted later and miss mentions restored through undo or clipboard paste. AI SDK's `convertToModelMessages` drops custom data parts unless you convert them explicitly. `resolveContextMessages` validates the message, looks up each ID on your server, and includes the stored document name and contents as text for the model. It rejects requests with missing or inaccessible documents. Call it from your route before invoking the model: ```ts title="Inside your authenticated chat route" const { messages } = await request.json(); const modelMessages = await resolveContextMessages(messages, async (id) => { // Implement this lookup with your application's verified user/session. return readDocumentForUser(user.id, id); }); ``` Your application supplies `user` and `readDocumentForUser`. The lookup returns `{ name, content }` or `null`. Filtering suggestions in the browser is not an access check. Resolve authorization again on every request, use server-owned document contents, and treat those contents as untrusted reference material in your model instructions. This starter handles text conversations and user document references. It rejects client-supplied system messages and unsupported message parts. Add your application's tool and metadata schemas when expanding it into a tool-enabled chat. References from previous turns are re-resolved against current permissions and contents; use versioned IDs and storage if your product requires historical snapshots. ## Suggestion positioning [#suggestion-positioning] The document panel anchors to the complete input group, prefers the space above it, and matches its width. This keeps document names and descriptions in a stable panel while the caret moves. If there is insufficient space above, Mention can flip the panel below and constrain it to available space. The composer uses these `Mention.Popover` props: ```tsx title="Anchor to the composer" const composer = useRef(null); // Inside Mention.Root: {/* Editor and controls */} {/* Mention.List and Mention.Empty */} ``` Caret anchoring remains the default when `anchorRef` is omitted or empty. `container` controls the portal destination independently. These positioning options do not change the editor's selection, insertion, or keyboard handling. ## Editing and recovery [#editing-and-recovery] * Enter commits a highlighted suggestion before it can submit. Shift+Enter inserts a line. Composition and modified keys remain with the editor. * Copy, paste, formatting, deletion, and undo use the same Lexical host as the [rich-editor integration](/docs/lexical). * A send captures the draft and references together. Editing pauses during the response; success clears only that submitted draft. * Failure and stop preserve the draft. **Retry response** retries the earlier user message without appending a duplicate. If you edited the draft after a failure, retry leaves that newer draft untouched. * `MessageScroller` follows the conversation within its own viewport and lets the reader scroll back. Mention scrolls its suggestion list independently. Automated browser checks do not establish real OS IME or screen-reader compatibility. See the [accessibility guide](/docs/accessibility) for those remaining checks. ## Source [#source] These are the files installed by the registry and run by this website. The editor host is also shared with the existing Lexical example. ```tsx title="ai-composer.tsx" // biome-ignore-all lint/suspicious/noArrayIndexKey: AI SDK appends parts at stable positions; streamed text has no part ID. "use client"; import { useChat } from "@ai-sdk/react"; import { Mention } from "@danielivanov/mention"; import { type ChatTransport, DefaultChatTransport } from "ai"; import { ArrowUpIcon, AtSignIcon, SquareIcon } from "lucide-react"; import { useId, useRef, useState } from "react"; import { Bubble, BubbleContent } from "@/components/ui/bubble"; import { Button } from "@/components/ui/button"; import { InputGroup, InputGroupAddon, InputGroupButton, } from "@/components/ui/input-group"; import { Message, MessageContent, MessageHeader, } from "@/components/ui/message"; import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, } from "@/components/ui/message-scroller"; import type { ContextDocument, ContextMessage, } from "@/registry/default/ai-composer/context-message"; import { LexicalMentionEditor, type LexicalMentionEditorHandle, } from "@/registry/default/ai-composer/mention-editor"; import "@danielivanov/mention/styles.css"; const defaultTransport = new DefaultChatTransport({ api: "/api/chat", }); export function AiComposer({ documents, transport = defaultTransport, active = true, }: { documents: ContextDocument[]; transport?: ChatTransport; active?: boolean; }) { const id = useId(); const editor = useRef(null); const composer = useRef(null); const sending = useRef(false); const completed = useRef(false); const submittedDraft = useRef(""); const [empty, setEmpty] = useState(true); const [sendingDraft, setSendingDraft] = useState(false); const { messages, status, error, sendMessage, regenerate, stop } = useChat({ transport, onFinish: ({ isAbort, isError }) => { completed.current = !isAbort && !isError; }, }); const busy = sendingDraft || status === "submitted" || status === "streaming"; async function submit(retry = false) { if (!active || sending.current || busy || !editor.current) return; const draft = editor.current.getSnapshot(); if (!retry && !draft.text.trim()) return; sending.current = true; setSendingDraft(true); completed.current = false; try { if (retry) { await regenerate(); } else { submittedDraft.current = editor.current.getJSON(); await sendMessage({ parts: [ { type: "text", text: draft.text }, ...(draft.references.length ? [{ type: "data-mentions" as const, data: draft.references }] : []), ], }); } // AI SDK reports transport failures through onError, not a rejected send promise. // Keep a failed/stopped draft. Retrying an old message must not clear a newer draft. if ( completed.current && editor.current?.getJSON() === submittedDraft.current ) { editor.current.clear(); } } finally { sending.current = false; setSendingDraft(false); } } return (
{messages.length > 0 && (
{messages.map((message) => ( {message.role === "user" ? "You" : "Assistant"} {message.parts.map((part, index) => part.type === "text" ? (

{part.text}

) : part.type === "data-mentions" ? (
    {part.data.map((reference) => (
  • {reference.name}
  • ))}
) : null, )}
))}
)} items={documents} allowSpaces getKey={(item) => item.id} getLabel={(item) => item.name} >
{ event.preventDefault(); void submit(); }} className="flex flex-col gap-2" > ref={editor} id={id} label="Message with context" active={active && !busy} data-slot="input-group-control" aria-describedby={`${id}-hint`} className="min-h-28 w-full min-w-0 whitespace-pre-wrap break-words p-3 text-base outline-none [&_.mention-token]:rounded-sm [&_.mention-token]:bg-muted [&_.mention-token]:text-foreground" onDocument={() => setEmpty(!editor.current?.getSnapshot().text.trim()) } onSubmit={() => void submit()} /> editor.current?.insertTrigger("@")} > Reference {busy ? ( void stop()} > ) : ( )}

Type @ to reference a document. Enter selects, then sends. Shift+Enter adds a line.

{busy ? "Receiving response…" : ""}

{error && (

The response failed. Your draft is still here.

)}
> {(document) => ( {document.name} {document.description && ( {document.description} )} )} No documents found. Try another name.
); } ``` ```ts title="context-message.ts" import type { UIMessage } from "ai"; export type MentionReference = { id: string; name: string }; export type ContextDocument = MentionReference & { description?: string }; export type ContextMessage = UIMessage; ``` ```ts title="resolve-context.ts" import { convertToModelMessages, validateUIMessages } from "ai"; import { z } from "zod"; import type { ContextMessage } from "@/registry/default/ai-composer/context-message"; const references = z .array( z.object({ id: z.string().min(1).max(200), name: z.string().min(1).max(200), }), ) .max(20); /** Call on the server. The resolver must enforce the signed-in user's access. */ export async function resolveContextMessages( input: unknown, resolve: (id: string) => Promise<{ name: string; content: string } | null>, ) { const messages = await validateUIMessages({ messages: input, dataSchemas: { mentions: references }, }); const ids = new Set(); for (const message of messages) { if (message.role !== "user" && message.role !== "assistant") { throw new Error("Only user and assistant messages are accepted."); } for (const part of message.parts) { // Normal SDK text streams include step boundaries in assistant history. if (part.type === "step-start" && message.role === "assistant") continue; if (part.type === "data-mentions" && message.role === "user") { for (const reference of part.data) ids.add(reference.id); } else if (part.type !== "text") { throw new Error( "This example accepts text and user document references.", ); } } } const documents = new Map(); await Promise.all( [...ids].map(async (id) => { const document = await resolve(id); if (!document) throw new Error("A referenced document is unavailable."); documents.set(id, document); }), ); return convertToModelMessages(messages, { convertDataPart(part) { // UI data parts are otherwise discarded by AI SDK. Use server-owned names/content. return { type: "text", text: part.data .map(({ id }) => { const document = documents.get(id); if (!document) throw new Error("A referenced document is unavailable."); return JSON.stringify({ reference: id, name: document.name, content: document.content, }); }) .join("\n"), }; }, }); } ``` ```tsx title="mention-editor.tsx" "use client"; import { type EditorAdapter, useMentionContext } from "@danielivanov/mention"; import { HistoryExtension } from "@lexical/history"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalExtensionComposer } from "@lexical/react/LexicalExtensionComposer"; import { RichTextExtension } from "@lexical/rich-text"; import { $create, $createParagraphNode, $createRangeSelectionFromDom, $createTextNode, $getDocument, $getRoot, $getSelection, $getState, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, $setState, createState, defineExtension, type EditorConfig, HISTORY_PUSH_TAG, type LexicalEditor, type LexicalNode, TextNode, } from "lexical"; import { type CSSProperties, type KeyboardEvent, type Ref, useImperativeHandle, useLayoutEffect, useRef, } from "react"; type MentionValue = { id: string; name: string }; const idState = createState("mentionId", { parse: (value) => (typeof value === "string" ? value : ""), }); const labelState = createState("mentionLabel", { parse: (value) => (typeof value === "string" ? value : ""), }); const triggerState = createState("mentionTrigger", { parse: (value) => (typeof value === "string" ? value : "@"), }); /** Token mode makes the entire mention one deletion while retaining Lexical's text formatting. */ export class MentionNode extends TextNode { override $config() { return this.config("mention", { extends: TextNode, stateConfigs: [ { stateConfig: idState, flat: true }, { stateConfig: labelState, flat: true }, { stateConfig: triggerState, flat: true }, ], importDOM: { span: (element) => element.hasAttribute("data-mention-id") ? { priority: 1, conversion: (element) => ({ node: $createMentionNode( element.getAttribute("data-mention-id") ?? "", element.getAttribute("data-mention-label") ?? "", element.textContent ?? "", element.getAttribute("data-mention-trigger") ?? "@", ) .setFormat( Number(element.getAttribute("data-mention-format")) || 0, ) .setStyle(element.getAttribute("style") ?? ""), }), } : null, }, }); } override createDOM(config: EditorConfig) { const element = super.createDOM(config); element.classList.add("mention-token"); element.dataset.mentionId = $getState(this, idState); element.dataset.mentionLabel = $getState(this, labelState); element.dataset.mentionTrigger = $getState(this, triggerState); return element; } override exportDOM(editor: LexicalEditor) { const { element } = super.exportDOM(editor); // TextNode can render strong/em/code. A canonical span keeps HTML-only paste // independent of its current formatting tag; JSON clipboard data is optional. const wrapper = $getDocument().createElement("span"); wrapper.dataset.mentionId = $getState(this, idState); wrapper.dataset.mentionLabel = $getState(this, labelState); wrapper.dataset.mentionTrigger = $getState(this, triggerState); wrapper.dataset.mentionFormat = String(this.getFormat()); wrapper.style.cssText = this.getStyle(); if (element) wrapper.append(element); return { element: wrapper }; } override canInsertTextBefore() { return false; } override canInsertTextAfter() { return false; } } function $createMentionNode( id: string, label: string, text: string, trigger: string, ) { const node = $create(MentionNode).setTextContent(text).setMode("token"); $setState(node, idState, id); $setState(node, labelState, label); $setState(node, triggerState, trigger); return node; } const extension = defineExtension({ name: "mention/lexical-example", namespace: "mention/lexical-example", nodes: [MentionNode], dependencies: [RichTextExtension, HistoryExtension], $initialEditorState: () => { $getRoot().append($createParagraphNode()); }, }); /** Map one block to UTF-16 offsets without scanning inside existing mention tokens. */ function $readRegion() { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null; const anchor = selection.anchor; let block: LexicalNode | null = anchor.getNode(); while (block && (!$isElementNode(block) || block.isInline())) block = block.getParent(); if (!$isElementNode(block) || block.getType() === "root") return null; let text = ""; let caret: number | null = null; const segments: { node: TextNode; from: number; to: number }[] = []; function visit(node: LexicalNode) { const from = text.length; if ($isElementNode(node)) { const children = node.getChildren(); children.forEach((child, index) => { if (anchor.key === node.getKey() && anchor.offset === index) caret = text.length; visit(child); }); if (anchor.key === node.getKey() && anchor.offset === children.length) caret = text.length; } else if ($isTextNode(node) && node.isSimpleText()) { text += node.getTextContent(); segments.push({ node, from, to: text.length }); if (anchor.key === node.getKey()) caret = from + anchor.offset; } else { text += $isLineBreakNode(node) ? "\n" : "\ufffc"; // A token's internal offsets are not editable positions. if (anchor.key === node.getKey() && anchor.offset === 0) caret = from; else if ( anchor.key === node.getKey() && anchor.offset === node.getTextContentSize() ) caret = text.length; } } visit(block); return caret === null ? null : { text, caret, key: block.getKey(), segments, selection }; } export interface LexicalMentionEditorHandle { focus(): void; clear(): void; insertTrigger(trigger: string): void; getJSON(): string; restoreJSON(json: string): void; getSnapshot(): { text: string; references: { id: string; name: string }[] }; } type EditorProps = { id?: string; label: string; className?: string; style?: CSSProperties; "aria-describedby"?: string; "aria-labelledby"?: string; placeholder?: string; active?: boolean; ref?: Ref; onEmptyChange?: (empty: boolean) => void; onDocument?: (json: string) => void; onSubmit?: () => void; "data-slot"?: string; }; function Editor({ ref, active = true, onDocument, onEmptyChange, onSubmit, placeholder, label, ...props }: EditorProps) { const [editor] = useLexicalComposerContext(); const mention = useMentionContext(); const latest = useRef({ mention, active, onDocument, onEmptyChange }); const composing = useRef(false); useLayoutEffect(() => { latest.current = { mention, active, onDocument, onEmptyChange }; }); useLayoutEffect(() => { editor.setEditable(active); if (!active) mention.setOpen(false); }, [active, editor, mention.setOpen]); useLayoutEffect(() => { const removeRoot = editor.registerRootListener((element) => { if (!element) { latest.current.mention.setEditor(null); return; } const adapter: EditorAdapter = { element, read() { if ( !latest.current.active || composing.current || editor.isComposing() || element.ownerDocument.activeElement !== element ) return null; return editor.getEditorState().read( () => { // Selectionchange can trail a native caret move. Never commit against the old model selection. const domSelection = $createRangeSelectionFromDom( element.ownerDocument.getSelection(), editor, ); const selection = $getSelection(); if ( !$isRangeSelection(selection) || !domSelection?.anchor.is(selection.anchor) || !domSelection.focus.is(selection.focus) ) return null; const region = $readRegion(); return ( region && { text: region.text, caret: region.caret, key: region.key, } ); }, { editor }, ); }, getCaretRect() { const selection = element.ownerDocument.getSelection(); if ( !selection?.rangeCount || !selection.isCollapsed || !element.contains(selection.anchorNode) ) return null; return selection.getRangeAt(0).getBoundingClientRect(); }, replace(edit, item, meta) { let applied = false; editor.update( () => { const region = $readRegion(); if ( !region || !latest.current.active || composing.current || editor.isComposing() ) return; const start = region.segments.find( ({ from, to }) => edit.from >= from && edit.from < to, ); const end = region.segments.find( ({ from, to }) => edit.to > from && edit.to <= to, ); if (!start || !end) return; const format = region.selection.format; region.selection.setTextNodeRange( start.node, edit.from - start.from, end.node, edit.to - end.from, ); const separator = edit.text.endsWith(" ") ? " " : ""; const text = separator ? edit.text.slice(0, -1) : edit.text; const node = $createMentionNode( item.id, item.name, text, meta.trigger, ).setFormat(format); region.selection.insertNodes( separator ? [node, $createTextNode(separator).setFormat(format)] : [node], ); applied = true; }, { discrete: true, tag: HISTORY_PUSH_TAG }, ); return applied; }, }; latest.current.mention.setEditor(adapter); }); const removeUpdate = editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { latest.current.onEmptyChange?.($getRoot().getTextContentSize() === 0); }); latest.current.onDocument?.(JSON.stringify(editorState.toJSON())); latest.current.mention.refresh(); }); return () => { removeUpdate(); removeRoot(); latest.current.mention.setEditor(null); }; }, [editor]); useImperativeHandle( ref, () => ({ focus() { if (latest.current.active) editor.focus(); }, clear() { editor.update( () => { $getRoot().clear().append($createParagraphNode()).selectEnd(); }, { discrete: true, tag: HISTORY_PUSH_TAG }, ); if (latest.current.active) editor.focus(); latest.current.mention.setOpen(false); }, insertTrigger(trigger) { if (!latest.current.active) return; editor.focus(() => { editor.update( () => { let selection = $getSelection(); if (!$isRangeSelection(selection)) { $getRoot().selectEnd(); selection = $getSelection(); } if (!$isRangeSelection(selection)) return; const region = $readRegion(); const prefix = region && region.caret > 0 && !/\s/.test(region.text[region.caret - 1] ?? "") ? " " : ""; selection.insertText(prefix + trigger); }, { discrete: true, tag: HISTORY_PUSH_TAG }, ); latest.current.mention.setOpen(true); }); }, getJSON() { return JSON.stringify(editor.getEditorState().toJSON()); }, getSnapshot() { return editor.getEditorState().read(() => { const references = new Map(); function visit(node: LexicalNode) { if (node instanceof MentionNode) { const id = $getState(node, idState); references.set(id, { id, name: $getState(node, labelState) }); } else if ($isElementNode(node)) { node.getChildren().forEach(visit); } } visit($getRoot()); return { text: $getRoot().getTextContent(), references: [...references.values()], }; }); }, restoreJSON(json) { editor.setEditorState(editor.parseEditorState(json), { tag: HISTORY_PUSH_TAG, }); latest.current.mention.setOpen(false); }, }), [editor], ); const relationships = mention.getEditorProps(); return ( mention.refresh()} onKeyDownCapture={(event) => { if (mention.handleKeyDown(event)) { event.stopPropagation(); return; } if ( onSubmit && isSubmitKey(event) && !composing.current && !editor.isComposing() ) { event.preventDefault(); event.stopPropagation(); onSubmit(); } }} onCompositionStart={() => { composing.current = true; mention.refresh(); }} onCompositionEnd={() => { queueMicrotask(() => { composing.current = false; latest.current.mention.refresh(); }); }} /> ); } function isSubmitKey(event: KeyboardEvent) { return ( event.key === "Enter" && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey && !event.nativeEvent.isComposing && !event.defaultPrevented ); } /** Example-level host integration, not an additional Mention package API. */ export function LexicalMentionEditor( props: EditorProps, ) { return ( {...props} /> ); } ``` The website demo uses `@shadcn/helpers/ai-sdk` for scripted responses through `useChat`. Its sample data and transport stay in this wrapper, which is excluded from the registry item: ```tsx title="AIComposerDemo.tsx" // biome-ignore-all lint/a11y/noNoninteractiveTabindex: The bounded request preview must be keyboard-scrollable. "use client"; import { createChat } from "@shadcn/helpers/ai-sdk"; import { useMemo, useRef, useState } from "react"; import { AiComposer } from "@/registry/default/ai-composer/ai-composer"; import type { ContextDocument, ContextMessage, } from "@/registry/default/ai-composer/context-message"; const sampleDocuments: ContextDocument[] = [ { id: "doc-pricing", name: "pricing.md", description: "Plans and pricing assumptions", }, { id: "doc-research", name: "customer research.md", description: "Interview notes and open questions", }, { id: "doc-roadmap", name: "roadmap.md", description: "What we are building next", }, ]; /** The scripted transport belongs to this demo; it is excluded from the registry item. */ export function AIComposerDemo({ active = true }: { active?: boolean }) { const [request, setRequest] = useState(null); const [failNext, setFailNext] = useState(false); const failure = useRef(false); const transport = useMemo(() => { const scripted = createChat().transport({ delayMs: 45, fallback: ({ writer, messages }) => { const message = [...messages] .reverse() .find((item) => item.role === "user"); const refs = message?.parts.flatMap((part) => part.type === "data-mentions" ? part.data : [], ) ?? []; writer .sleep(200) .text( refs.length ? `Your message includes references to ${refs.map((ref) => ref.name).join(" and ")}.` : "Your message has no document references.", ); }, }); return { ...scripted, async sendMessages(options: Parameters[0]) { setRequest( [...options.messages] .reverse() .find((message) => message.role === "user") ?? null, ); if (failure.current) { failure.current = false; setFailNext(false); throw new Error("Example connection failure"); } return scripted.sendMessages(options); }, }; }, []); return (
Choose what the assistant receives Sample responses
No model or network request
{request && (
View submitted context
            {JSON.stringify(request.parts, null, 2)}
          
)}
); } ``` --- # API reference Components, hooks, and editor transactions. Canonical: https://reactmention.com/docs/api-reference ## Root and channels [#root-and-channels] `Mention.Root` and `useMention()` accept: | Prop | Purpose | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `items` | Array of items, or `(query, signal) => Promise`. | | `getKey` | Unique, stable string or number for each item. | | `getLabel` | Search label and default insertion label. | | `allowSpaces` | Allow horizontal spaces in this channel’s query. Defaults to false; tabs, line breaks, and editor atoms still stop detection. | | `filter` | Optional synchronous `(item, query) => boolean` for arrays. Defaults to case-insensitive label substring matching; fetcher results bypass it. | | `trigger` | One non-whitespace UTF-16 character; defaults to `@`. | | `getInsertText` | Optional `(item, meta) => string`. Defaults to trigger + label. | | `onSelect` | Optional notification after successful insertion. | | `debounceMs` | Async request delay, default 150 ms. Use 0 for immediate requests. | A separating space is appended when neither the insertion nor the following text already supplies whitespace. For multiple channels, Root and `useMentionMulti()` accept a `triggers` map instead of a single channel. Each channel has `items`, `getKey`, `getLabel`, and optional `getInsertText`, `allowSpaces`, and `filter`. The multi-channel `onSelect` payload is keyed by the active trigger, for example `{ "@": person }`. Root also accepts `children`, `unstyled`, and `handleRef`. The handle exposes `open()`, `close()`, `commit(item)`, and `host`. Opening rescans the existing text at the caret and retries a failed query; it does not insert a trigger or duplicate a pending or successful request. `MentionSelectMeta` contains `trigger`, `query`, and `triggerOffset`. Offsets use UTF-16 within the adapter's current text region. ## Compound components [#compound-components] | Component | Behavior | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `Mention.Input` | Native textarea. Accepts standard event handlers, controlled values, refs, styles, and form attributes. | | `Mention.Popover` | Listbox anchored to the caret by default, hidden after a failed search. Accepts div props and the positioning options below. | | `Mention.List` | Calls its child function for each result. Optional `trigger` restricts the active channel. | | `Mention.Item` | Option with a `value`, children, and normal div props. | | `Mention.Loading` | Renders while a request is pending. | | `Mention.Empty` | Renders only after a successful query with no results. | React context cannot infer a List's item type from Root. Supply `Mention.List` and ensure that its type matches the selected channel. The same responsibility applies to `useMentionContext()`. ### Popover positioning [#popover-positioning] | Prop | Behavior | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `anchorRef` | Optional `RefObject` for an element such as the composer wrapper. An omitted or empty ref keeps caret anchoring. | | `placement` | Preferred side and alignment: `top`, `right`, `bottom`, or `left`, optionally suffixed with `-start` or `-end`. Defaults to `bottom-start`; flips when space is constrained. | | `matchAnchorWidth` | Match the reference width, capped by available viewport space. Defaults to false. Use with an element anchor for a full-width composer panel. | | `maxHeight` | Maximum height in pixels, also limited by available space. Defaults to 280. | | `container` | Portal destination. Defaults to the editor document's body; `null` renders in place. This does not select the anchor. | For a panel above the whole composer, attach a `useRef(null)` to its wrapper and pass it as `anchorRef`, with `placement="top-start"` and `matchAnchorWidth`. See the [AI composer](/docs/ai-composer#suggestion-positioning) for the executable example. Mention retains collision handling and updates the position on relevant scrolling and resizing. ## Hooks [#hooks] `useMention()` and `useMentionMulti()` expose: | Member | Purpose | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `open`, `query`, `activeTrigger` | Popup visibility, query, and visible channel. A failed search keeps its query but sets `open` to false and `activeTrigger` to null. | | `items`, `status` | Current results and `idle / loading / success / error` status. Old requests never supply current results. | | `highlightedIndex` | Active option index, or -1. | | `getInputProps(props?)` | Composes textarea props and registers the built-in adapter through its ref. Pass handlers into this method rather than overwriting its returned handlers. | | `getEditorProps()` | ARIA attributes for a host with its own event system. | | `getPopoverProps()` | Listbox attributes. | | `getItemProps(item, index, props?)` | Option attributes and composed pointer handlers. Supply React keys separately. | | `setOpen(boolean)` | Dismiss or rescan the current selection. Opening a failed search starts a new request for that query. | | `commit(item)` | Returns false if the item, text, or selection is no longer current. | | `editor`, `setEditor(adapter)` | Current host and registration. Clear on cleanup. | | `refresh()` | Rescan after an editor transaction or selection change. | | `handleKeyDown(event)` | Returns true when handled. Call before editor key bindings. | `useMentionContext()` returns this interface inside Root. For standalone hooks, render the popup with the returned props. A hook's popup styling and positioning are application-owned. Values belong on the textarea's standard `value` and `onChange` props. There is no second value state on the mention hook. ## Editor adapter [#editor-adapter] ```ts interface EditorSnapshot { text: string; caret: number; key?: unknown; } interface MentionEdit { from: number; to: number; text: string; } interface EditorAdapter { element: HTMLElement; read(): EditorSnapshot | null; getCaretRect(): DOMRect | null; replace(edit: MentionEdit, item: T, meta: MentionSelectMeta): void | boolean; } ``` Return null when editing is unavailable, a selection spans a range, or composition is active. Keep offsets consistent within one editable region. Use `key` to distinguish regions with identical text. Replacements must use the host's transaction system; return false if the operation is rejected. See [the working editor integration](/docs/rich-text). ## Detection utility [#detection-utility] `findActiveMention(value, caret, trigger = "@", { allowSpaces: false })` returns `{ trigger, query }` or null. The trigger can also be an array of characters. It scans backwards from the caret and suppresses mid-word triggers, with soft boundaries for selected Unicode scripts. Pass `{ allowSpaces: true }` to allow Unicode horizontal space separators (`Zs`) in the query. Tabs, line breaks, and `U+FFFC` editor atoms always stop the scan. See [name matching](/docs/recipes/i18n#name-matching) for an opt-in local filter. --- # Quickstart Add typed mention suggestions to a React textarea. Canonical: https://reactmention.com/docs Mention opens suggestions at the caret while someone writes. Start with a native textarea for plain text, or [connect a rich-text editor](/docs/rich-text) when your document needs mention nodes, formatting, or editor history. ## Install [#install] Use an application with **React 19**, then install the package: ```sh title="Install Mention" npm install @danielivanov/mention@^0.2.1 ``` With Bun, use `bun add @danielivanov/mention@^0.2.1`. These examples require Mention 0.2.1. The stylesheet is optional; import it once for the default popup and option styles. ## Try the interaction [#try-the-interaction] Type `@al` below to find Alice. Use ArrowDown and ArrowUp to navigate; Enter or Tab inserts the highlighted result. Escape dismisses suggestions without changing the message. Focus stays in the textarea, and Enter without an active suggestion adds a new line.
Selecting Alice inserts plain text, `@Alice`, followed by a separating space when needed. Arrays are filtered by a case-insensitive substring match on `getLabel`. ## Add a composer [#add-a-composer] This is the complete source of the example above. The rendered example and this code block use the same file. In a Next.js App Router application, keep `"use client"` at the top: state, event handlers, and item render functions belong in a client component. Style the native label, hint, and textarea with your application's existing styles. The optional package stylesheet styles the suggestions. ```tsx title="Composer.tsx" "use client"; import { Mention } from "@danielivanov/mention"; import { useId, useState } from "react"; import "@danielivanov/mention/styles.css"; type Person = { id: string; name: string }; const people: Person[] = [ { id: "alice", name: "Alice" }, { id: "bob", name: "Bob" }, { id: "carol", name: "Carol" }, ]; export function Composer() { const id = useId(); const [message, setMessage] = useState(""); return ( items={people} getKey={(person) => person.id} getLabel={(person) => person.name} >

Type @ to find a person. Use the arrow keys to choose, Enter to insert, and Escape to dismiss.

setMessage(event.currentTarget.value)} aria-describedby={`${id}-hint`} placeholder="Write a message…" rows={4} /> > {(person) => ( {person.name} )} No people found. Try another name. ); } ``` `getKey` supplies each item's stable identity. `getLabel` supplies the local search label and default insertion label. Give `Mention.List` its item type explicitly: React context cannot infer that type from `Mention.Root`. The input supports normal textarea props, including `value`, `onChange`, `onBlur`, `onKeyDown`, and `ref`. A consumer key handler runs first and can call `preventDefault()` to override Mention's handling. `onSelect` on Root is optional; use it only when you need a notification after insertion. The example uses `container={null}` to keep the listbox within the article's landmark. Use a portal container when your application's clipping or layering requires one. ## Choose your next step [#choose-your-next-step] * [Controlled forms](/docs/recipes/controlled-value): keep text in your form's existing state. * [Async search](/docs/recipes/async-items): fetch suggestions with debouncing, cancellation, and status feedback. * [Multiple triggers](/docs/recipes/multi-trigger): combine people, channels, or other item types in one host. * [Styling](/docs/recipes/styling): use the optional stylesheet or your own design system. * [Rich-text editors](/docs/rich-text): keep structured mentions and edits in an editor document. * [API reference](/docs/api-reference): inspect component, hook, and adapter contracts. * [Coding-agent guide](/docs/agents): hand an integration task to a coding agent. ## Verify in your application [#verify-in-your-application] Check insertion, empty results, Escape, focus, and ordinary typing with your actual form state. Test the popup inside your dialogs and scrolling containers. For asynchronous data, also test requests resolving out of order. Read [Accessibility](/docs/accessibility) for the native textbox and listbox relationship, keyboard behavior, and manual verification guidance. Automated checks do not establish screen-reader or real OS IME compatibility. --- # Lexical Keep mention tokens, formatting, clipboard data, and history in Lexical. Canonical: https://reactmention.com/docs/lexical Connect Mention to Lexical through an `EditorAdapter`. Mention supplies the suggestions; Lexical inserts a token node in its own transaction. The example uses Lexical 0.50's extension composer with `RichTextExtension` and `HistoryExtension`. ## Try the integration [#try-the-integration] Type `@Al`, select Alice, then continue writing. Insert another mention in a new paragraph, copy and paste it, or undo the insertion. **Save snapshot** stores the editor's JSON in this example's memory; **Restore snapshot** reloads that document, including mention IDs and formatting. Reloading this page discards the snapshot.
## Own the document [#own-the-document] `MentionNode` extends Lexical's `TextNode` in token mode, so Backspace removes a complete mention. Its node state stores the stable item ID, display name, and channel trigger, while the text is the channel's formatted insertion. Lexical handles formatting, history, JSON serialization, and its native clipboard format. The node's DOM conversion preserves its ID and label when pasted as HTML; plain-text destinations receive its readable text. Treat imported IDs as application data. A copied or saved mention is not evidence that a person exists or that the current user may notify them; resolve and validate references in your application. Derive the remaining mention inventory from the document. `onSelect` reports an insertion and does not track later deletion, paste, or undo. ## Map selection without flattening the document [#map-selection-without-flattening-the-document] The adapter scans one block at the collapsed selection. Normal text contributes UTF-16 offsets, including across differently formatted text nodes. Existing tokens contribute a single `\uFFFC` placeholder. A block's key distinguishes otherwise identical paragraphs. The insertion translates those offsets back into Lexical text points and applies a single transaction. It preserves the active formatting and the separator chosen by Mention. The editor returns no snapshot while unfocused, inactive, or composing; editor updates refresh the suggestion session. The example uses Lexical's DOM selection to measure the caret and keeps keyboard focus in the editing host. ## Copy the full implementation [#copy-the-full-implementation] Install the editor dependencies alongside Mention: ```sh title="Install Lexical" npm install lexical@0.50.0 @lexical/react@0.50.0 @lexical/rich-text@0.50.0 @lexical/history@0.50.0 ``` Import `@danielivanov/mention/styles.css` for the default suggestion styles. Add your own styles for `.mention-token` and `.lexical-editor`. The host below is shared by this page, the landing's rich-editor mode, the [AI composer](/docs/ai-composer), and the browser fixtures. `LexicalMentionEditor` and its small handle are example-level application code, not additional exports from Mention. Keep the demo's host import pointed at wherever you save the first file. ```tsx title="mention-editor.tsx" "use client"; import { type EditorAdapter, useMentionContext } from "@danielivanov/mention"; import { HistoryExtension } from "@lexical/history"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalExtensionComposer } from "@lexical/react/LexicalExtensionComposer"; import { RichTextExtension } from "@lexical/rich-text"; import { $create, $createParagraphNode, $createRangeSelectionFromDom, $createTextNode, $getDocument, $getRoot, $getSelection, $getState, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, $setState, createState, defineExtension, type EditorConfig, HISTORY_PUSH_TAG, type LexicalEditor, type LexicalNode, TextNode, } from "lexical"; import { type CSSProperties, type KeyboardEvent, type Ref, useImperativeHandle, useLayoutEffect, useRef, } from "react"; type MentionValue = { id: string; name: string }; const idState = createState("mentionId", { parse: (value) => (typeof value === "string" ? value : ""), }); const labelState = createState("mentionLabel", { parse: (value) => (typeof value === "string" ? value : ""), }); const triggerState = createState("mentionTrigger", { parse: (value) => (typeof value === "string" ? value : "@"), }); /** Token mode makes the entire mention one deletion while retaining Lexical's text formatting. */ export class MentionNode extends TextNode { override $config() { return this.config("mention", { extends: TextNode, stateConfigs: [ { stateConfig: idState, flat: true }, { stateConfig: labelState, flat: true }, { stateConfig: triggerState, flat: true }, ], importDOM: { span: (element) => element.hasAttribute("data-mention-id") ? { priority: 1, conversion: (element) => ({ node: $createMentionNode( element.getAttribute("data-mention-id") ?? "", element.getAttribute("data-mention-label") ?? "", element.textContent ?? "", element.getAttribute("data-mention-trigger") ?? "@", ) .setFormat( Number(element.getAttribute("data-mention-format")) || 0, ) .setStyle(element.getAttribute("style") ?? ""), }), } : null, }, }); } override createDOM(config: EditorConfig) { const element = super.createDOM(config); element.classList.add("mention-token"); element.dataset.mentionId = $getState(this, idState); element.dataset.mentionLabel = $getState(this, labelState); element.dataset.mentionTrigger = $getState(this, triggerState); return element; } override exportDOM(editor: LexicalEditor) { const { element } = super.exportDOM(editor); // TextNode can render strong/em/code. A canonical span keeps HTML-only paste // independent of its current formatting tag; JSON clipboard data is optional. const wrapper = $getDocument().createElement("span"); wrapper.dataset.mentionId = $getState(this, idState); wrapper.dataset.mentionLabel = $getState(this, labelState); wrapper.dataset.mentionTrigger = $getState(this, triggerState); wrapper.dataset.mentionFormat = String(this.getFormat()); wrapper.style.cssText = this.getStyle(); if (element) wrapper.append(element); return { element: wrapper }; } override canInsertTextBefore() { return false; } override canInsertTextAfter() { return false; } } function $createMentionNode( id: string, label: string, text: string, trigger: string, ) { const node = $create(MentionNode).setTextContent(text).setMode("token"); $setState(node, idState, id); $setState(node, labelState, label); $setState(node, triggerState, trigger); return node; } const extension = defineExtension({ name: "mention/lexical-example", namespace: "mention/lexical-example", nodes: [MentionNode], dependencies: [RichTextExtension, HistoryExtension], $initialEditorState: () => { $getRoot().append($createParagraphNode()); }, }); /** Map one block to UTF-16 offsets without scanning inside existing mention tokens. */ function $readRegion() { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null; const anchor = selection.anchor; let block: LexicalNode | null = anchor.getNode(); while (block && (!$isElementNode(block) || block.isInline())) block = block.getParent(); if (!$isElementNode(block) || block.getType() === "root") return null; let text = ""; let caret: number | null = null; const segments: { node: TextNode; from: number; to: number }[] = []; function visit(node: LexicalNode) { const from = text.length; if ($isElementNode(node)) { const children = node.getChildren(); children.forEach((child, index) => { if (anchor.key === node.getKey() && anchor.offset === index) caret = text.length; visit(child); }); if (anchor.key === node.getKey() && anchor.offset === children.length) caret = text.length; } else if ($isTextNode(node) && node.isSimpleText()) { text += node.getTextContent(); segments.push({ node, from, to: text.length }); if (anchor.key === node.getKey()) caret = from + anchor.offset; } else { text += $isLineBreakNode(node) ? "\n" : "\ufffc"; // A token's internal offsets are not editable positions. if (anchor.key === node.getKey() && anchor.offset === 0) caret = from; else if ( anchor.key === node.getKey() && anchor.offset === node.getTextContentSize() ) caret = text.length; } } visit(block); return caret === null ? null : { text, caret, key: block.getKey(), segments, selection }; } export interface LexicalMentionEditorHandle { focus(): void; clear(): void; insertTrigger(trigger: string): void; getJSON(): string; restoreJSON(json: string): void; getSnapshot(): { text: string; references: { id: string; name: string }[] }; } type EditorProps = { id?: string; label: string; className?: string; style?: CSSProperties; "aria-describedby"?: string; "aria-labelledby"?: string; placeholder?: string; active?: boolean; ref?: Ref; onEmptyChange?: (empty: boolean) => void; onDocument?: (json: string) => void; onSubmit?: () => void; "data-slot"?: string; }; function Editor({ ref, active = true, onDocument, onEmptyChange, onSubmit, placeholder, label, ...props }: EditorProps) { const [editor] = useLexicalComposerContext(); const mention = useMentionContext(); const latest = useRef({ mention, active, onDocument, onEmptyChange }); const composing = useRef(false); useLayoutEffect(() => { latest.current = { mention, active, onDocument, onEmptyChange }; }); useLayoutEffect(() => { editor.setEditable(active); if (!active) mention.setOpen(false); }, [active, editor, mention.setOpen]); useLayoutEffect(() => { const removeRoot = editor.registerRootListener((element) => { if (!element) { latest.current.mention.setEditor(null); return; } const adapter: EditorAdapter = { element, read() { if ( !latest.current.active || composing.current || editor.isComposing() || element.ownerDocument.activeElement !== element ) return null; return editor.getEditorState().read( () => { // Selectionchange can trail a native caret move. Never commit against the old model selection. const domSelection = $createRangeSelectionFromDom( element.ownerDocument.getSelection(), editor, ); const selection = $getSelection(); if ( !$isRangeSelection(selection) || !domSelection?.anchor.is(selection.anchor) || !domSelection.focus.is(selection.focus) ) return null; const region = $readRegion(); return ( region && { text: region.text, caret: region.caret, key: region.key, } ); }, { editor }, ); }, getCaretRect() { const selection = element.ownerDocument.getSelection(); if ( !selection?.rangeCount || !selection.isCollapsed || !element.contains(selection.anchorNode) ) return null; return selection.getRangeAt(0).getBoundingClientRect(); }, replace(edit, item, meta) { let applied = false; editor.update( () => { const region = $readRegion(); if ( !region || !latest.current.active || composing.current || editor.isComposing() ) return; const start = region.segments.find( ({ from, to }) => edit.from >= from && edit.from < to, ); const end = region.segments.find( ({ from, to }) => edit.to > from && edit.to <= to, ); if (!start || !end) return; const format = region.selection.format; region.selection.setTextNodeRange( start.node, edit.from - start.from, end.node, edit.to - end.from, ); const separator = edit.text.endsWith(" ") ? " " : ""; const text = separator ? edit.text.slice(0, -1) : edit.text; const node = $createMentionNode( item.id, item.name, text, meta.trigger, ).setFormat(format); region.selection.insertNodes( separator ? [node, $createTextNode(separator).setFormat(format)] : [node], ); applied = true; }, { discrete: true, tag: HISTORY_PUSH_TAG }, ); return applied; }, }; latest.current.mention.setEditor(adapter); }); const removeUpdate = editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { latest.current.onEmptyChange?.($getRoot().getTextContentSize() === 0); }); latest.current.onDocument?.(JSON.stringify(editorState.toJSON())); latest.current.mention.refresh(); }); return () => { removeUpdate(); removeRoot(); latest.current.mention.setEditor(null); }; }, [editor]); useImperativeHandle( ref, () => ({ focus() { if (latest.current.active) editor.focus(); }, clear() { editor.update( () => { $getRoot().clear().append($createParagraphNode()).selectEnd(); }, { discrete: true, tag: HISTORY_PUSH_TAG }, ); if (latest.current.active) editor.focus(); latest.current.mention.setOpen(false); }, insertTrigger(trigger) { if (!latest.current.active) return; editor.focus(() => { editor.update( () => { let selection = $getSelection(); if (!$isRangeSelection(selection)) { $getRoot().selectEnd(); selection = $getSelection(); } if (!$isRangeSelection(selection)) return; const region = $readRegion(); const prefix = region && region.caret > 0 && !/\s/.test(region.text[region.caret - 1] ?? "") ? " " : ""; selection.insertText(prefix + trigger); }, { discrete: true, tag: HISTORY_PUSH_TAG }, ); latest.current.mention.setOpen(true); }); }, getJSON() { return JSON.stringify(editor.getEditorState().toJSON()); }, getSnapshot() { return editor.getEditorState().read(() => { const references = new Map(); function visit(node: LexicalNode) { if (node instanceof MentionNode) { const id = $getState(node, idState); references.set(id, { id, name: $getState(node, labelState) }); } else if ($isElementNode(node)) { node.getChildren().forEach(visit); } } visit($getRoot()); return { text: $getRoot().getTextContent(), references: [...references.values()], }; }); }, restoreJSON(json) { editor.setEditorState(editor.parseEditorState(json), { tag: HISTORY_PUSH_TAG, }); latest.current.mention.setOpen(false); }, }), [editor], ); const relationships = mention.getEditorProps(); return ( mention.refresh()} onKeyDownCapture={(event) => { if (mention.handleKeyDown(event)) { event.stopPropagation(); return; } if ( onSubmit && isSubmitKey(event) && !composing.current && !editor.isComposing() ) { event.preventDefault(); event.stopPropagation(); onSubmit(); } }} onCompositionStart={() => { composing.current = true; mention.refresh(); }} onCompositionEnd={() => { queueMicrotask(() => { composing.current = false; latest.current.mention.refresh(); }); }} /> ); } function isSubmitKey(event: KeyboardEvent) { return ( event.key === "Enter" && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey && !event.nativeEvent.isComposing && !event.defaultPrevented ); } /** Example-level host integration, not an additional Mention package API. */ export function LexicalMentionEditor( props: EditorProps, ) { return ( {...props} /> ); } ``` ```tsx title="Lexical.tsx" "use client"; import { Mention } from "@danielivanov/mention"; import { useRef, useState } from "react"; import { LexicalMentionEditor, type LexicalMentionEditorHandle, } from "./registry/default/ai-composer/mention-editor"; export { LexicalMentionEditor, type LexicalMentionEditorHandle, } from "./registry/default/ai-composer/mention-editor"; type MentionValue = { id: string; name: string }; const people = [ { id: "alice", name: "Alice Chen" }, { id: "bob", name: "Bob Rivera" }, { id: "jose", name: "José García" }, ]; export function LexicalDemo() { const editor = useRef(null); const [document, setDocument] = useState(""); const [saved, setSaved] = useState(null); const [status, setStatus] = useState(""); return ( items={people} allowSpaces filter={(person, query) => { const fold = (text: string) => text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase(); return fold(person.name).includes(fold(query)); }} getKey={(person) => person.id} getLabel={(person) => person.name} >

Type @ to insert a person. Use your usual bold and undo shortcuts. Save a snapshot, edit, then restore it.

ref={editor} id="lexical-message" label="Lexical message" aria-describedby="lexical-hint" className="lexical-editor" onDocument={setDocument} style={{ minHeight: 140, border: "1px solid currentColor", borderRadius: 6, padding: 12, whiteSpace: "pre-wrap", }} /> > {(person) => ( {person.name} )} No people found

{status}

); } ``` Adapt nodes, parsing, and history to your application's schema. See [editor ownership](/docs/internals) for the contract and [accessibility](/docs/accessibility) for the remaining real-device and assistive-technology checks. The example opts into full-name queries and local accent folding: `@jose gar` matches José García. Both policies are supplied per channel; see [name matching](/docs/recipes/i18n#name-matching). --- # Rich-text editors Keep documents, chips, and undo inside the editor. Canonical: https://reactmention.com/docs/rich-text Mention owns suggestions. Your editor owns the document and every edit to it. This includes mention nodes, selection, serialization, clipboard handling, and history. The ProseMirror example below uses real mention nodes and editor transactions. [The Lexical integration](/docs/lexical) provides a second executable example with its own nodes, selection mapping, clipboard serialization, and history. Its browser tests cover inserting mentions across paragraphs, preserving formatting during paste, undo, redo, and node deletion. ## Try ProseMirror [#try-prosemirror] Type `@Alice` in the rich message field, press Enter to insert, then undo. Add another paragraph and mention Bob. The first mention remains a document node.
## Connect an editor [#connect-an-editor] Inside `Mention.Root`, call `useMentionContext()`. Register an `EditorAdapter` with `setEditor(adapter)`, and clear it with `setEditor(null)` when the editor is destroyed. The adapter has three operations: 1. `read()` returns a text region and a collapsed caret, or null when editing is unavailable. A paragraph is a useful region; scan boundaries must not join adjacent blocks. Include a `key` when two regions could have identical text. 2. `getCaretRect()` returns viewport coordinates from the editor's selection API. 3. `replace(edit, item, meta)` applies one range replacement through an editor transaction. Insert a text node or a mention node according to your schema. After every document or selection transaction, call `refresh()`. Forward keyboard events to `handleKeyDown(event)` before your editor's other key bindings. Keep the editable host a multiline textbox (`role="textbox"`, `aria-multiline="true"`) with an accessible name. Apply `getEditorProps()` to update its suggestion relationships as the state changes; the getter does not assign the host's role. During composition, `read()` should return null and the editor must preserve its own IME handling. ## Position mapping [#position-mapping] Offsets in `EditorSnapshot` and `MentionEdit` use UTF-16 and must agree. The ProseMirror example scans the current paragraph, represents each inline atom with one placeholder character, and translates range offsets back into document positions. It never flattens the document or replaces its DOM. The repository example at `packages/react/examples/ProseMirror.tsx` also demonstrates schema serialization and history grouping. Other editors can use this interface, but their adapters need independent validation against their selection and transaction models. The ProseMirror and [Lexical](/docs/lexical) examples each have their own browser tests; they do not establish compatibility with Tiptap, Slate, or other editors. ## Copy the full implementation [#copy-the-full-implementation] Install the example's editor dependencies alongside Mention: ```sh title="Install ProseMirror" npm install prosemirror-model prosemirror-state prosemirror-view prosemirror-history prosemirror-keymap prosemirror-commands ``` Import `@danielivanov/mention/styles.css` for the default suggestion styles. The complete file below is the source used by the live example and its browser tests. It includes the schema, adapter, transactions, attribute updates, and cleanup; no separate adapter snippet is maintained. ```tsx title="ProseMirror.tsx" "use client"; import { type EditorAdapter, Mention, useMentionContext, } from "@danielivanov/mention"; import { baseKeymap, toggleMark } from "prosemirror-commands"; import { closeHistory, history, redo, undo } from "prosemirror-history"; import { keymap } from "prosemirror-keymap"; import { Schema } from "prosemirror-model"; import { EditorState, TextSelection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { useLayoutEffect, useRef, useState } from "react"; type Person = { id: string; name: string }; const people: Person[] = [ { id: "alice", name: "Alice" }, { id: "bob", name: "Bob" }, ]; const schema = new Schema({ nodes: { doc: { content: "paragraph+" }, paragraph: { content: "inline*", group: "block", toDOM: () => ["p", 0], parseDOM: [{ tag: "p" }], }, text: { group: "inline" }, mention: { group: "inline", inline: true, atom: true, attrs: { id: {}, label: {} }, toDOM: (node) => [ "span", { "data-mention-id": node.attrs.id, "data-mention-label": node.attrs.label, contenteditable: "false", style: "background:#e0e7ff;color:#312e81;border-radius:4px;padding:0 3px", }, `@${node.attrs.label}`, ], parseDOM: [ { tag: "span[data-mention-id]", getAttrs: (el) => ({ id: el.dataset.mentionId, label: el.dataset.mentionLabel, }), }, ], leafText: (node) => `@${node.attrs.label}`, }, }, marks: { strong: { toDOM: () => ["strong", 0], parseDOM: [{ tag: "strong" }, { tag: "b" }], }, }, }); function Editor({ onDocument }: { onDocument: (doc: string) => void }) { const mention = useMentionContext(); const latest = useRef(mention); const host = useRef(null); const viewRef = useRef(null); useLayoutEffect(() => { latest.current = mention; }); useLayoutEffect(() => { const view = new EditorView(host.current!, { state: EditorState.create({ schema, plugins: [ history(), keymap({ "Mod-z": undo, "Mod-Shift-z": redo, "Mod-y": redo, "Mod-b": toggleMark(schema.marks.strong!), }), keymap(baseKeymap), ], }), attributes: { role: "textbox", "aria-label": "Rich message", "aria-multiline": "true", style: "min-height:100px;border:1px solid #999;padding:8px;white-space:pre-wrap", }, handleKeyDown: (_view, event) => latest.current.handleKeyDown(event), handleDOMEvents: { compositionend: () => { queueMicrotask(() => latest.current.refresh()); return false; }, }, dispatchTransaction(transaction) { view.updateState(view.state.apply(transaction)); onDocument(JSON.stringify(view.state.doc.toJSON())); latest.current.refresh(); }, }); viewRef.current = view; const adapter: EditorAdapter = { element: view.dom, read() { const { empty, $from } = view.state.selection; if ( !empty || !$from.parent.isTextblock || view.composing || !view.hasFocus() ) return null; // One placeholder per atom preserves ProseMirror's position units. // Only the current paragraph is scanned, so mentions cannot cross blocks. return { text: $from.parent.textBetween( 0, $from.parent.content.size, "", "\ufffc", ), caret: $from.parentOffset, key: $from.start(), }; }, getCaretRect() { const { left, top, bottom } = view.coordsAtPos( view.state.selection.from, ); return new DOMRect(left, top, 0, bottom - top); }, replace(edit, person) { const { $from } = view.state.selection; const marks = view.state.storedMarks ?? $from.marks(); const from = $from.start() + edit.from; const to = $from.start() + edit.to; const node = schema.nodes.mention!.create( { id: person.id, label: person.name }, null, marks, ); const transaction = closeHistory(view.state.tr).replaceWith(from, to, [ node, schema.text(" ", marks), ]); transaction.setSelection( TextSelection.create(transaction.doc, from + 2), ); view.dispatch(transaction.scrollIntoView()); // Subsequent typing gets its own undo group. view.dispatch(closeHistory(view.state.tr)); view.focus(); }, }; latest.current.setEditor(adapter); return () => { latest.current.setEditor(null); viewRef.current = null; view.destroy(); }; }, [onDocument]); useLayoutEffect(() => { const attributes: Record = { role: "textbox", "aria-label": "Rich message", "aria-multiline": "true", style: "min-height:100px;border:1px solid #999;padding:8px;white-space:pre-wrap", }; for (const [key, value] of Object.entries(mention.getEditorProps())) { if (value !== undefined) attributes[key] = String(value); } viewRef.current?.setProps({ attributes }); }); return
; } export function ProseMirrorDemo() { const [document, setDocument] = useState(""); return ( items={people} getKey={(p) => p.id} getLabel={(p) => p.name} > > {(person) => ( {person.name} )} No people found {document} ); } ``` Adapt the schema, labels, styling, and history behavior to your editor. Keep the snapshot and replacement offsets consistent, and verify insertion, formatting, undo/redo, and selection changes in your actual document model. [Editor ownership](/docs/internals) explains that boundary in more detail. --- # Troubleshooting Diagnose detection, editor registration, positioning, and async behavior. Canonical: https://reactmention.com/docs/troubleshooting ## Suggestions do not open [#suggestions-do-not-open] Check that the trigger begins a word, composition has ended, and the host is editable with a collapsed selection. A bare hook must spread `getInputProps()` onto its textarea. A custom editor must call `setEditor()` and `refresh()`. An empty successful result still opens the popup; add `Mention.Empty` to explain that state. ## A selection does not commit [#a-selection-does-not-commit] Commit rejects obsolete results and changed selections. Move the caret to the intended query or type again. A custom adapter should report the current text and caret together and apply replacements using the same offset units. ## Controlled values do not update [#controlled-values-do-not-update] Use `value` and `onChange` on `Mention.Input`. For hooks, pass them into `getInputProps({ value, onChange })`. Do not overwrite the returned handlers or ref afterward. ## Async results disappear while typing [#async-results-disappear-while-typing] Old results are intentionally removed as soon as a query changes. Display `Mention.Loading` while waiting. Keep the fetcher reference stable unless its behavior changes. Forward the abort signal to network requests to avoid unnecessary work; the library also rejects late results when the fetcher ignores cancellation. ## Caret positioning drifts [#caret-positioning-drifts] The built-in textarea mirror measures browser text layout. Font loading, transformed ancestors, and CSS zoom can change that geometry. Reproduce without those factors first. A rich editor adapter should use its editor's viewport caret measurement. ## Accessibility checker reports a scrolling list [#accessibility-checker-reports-a-scrolling-list] Mention uses the focused textbox to navigate suggestions and scroll the highlighted option into view. Axe can report `scrollable-region-focusable` because its heuristic recognizes combobox-controlled popups but not textbox-controlled ones. Check arrow-key access to every option and preserve the host's focus. See [Accessibility](/docs/accessibility#verification) for the recorded findings and manual verification limits. ## Popup is clipped or outside the desired reading order [#popup-is-clipped-or-outside-the-desired-reading-order] The default popup portals into the host document's body. Set `container` to an appropriate application container, or `container={null}` to render in place. Test the resulting layout and reading order with the assistive technologies your application supports. ## Undo differs between hosts [#undo-differs-between-hosts] Rich editor history belongs to its transaction system. The textarea adapter uses the browser's insertion command to preserve native history where supported. WebKit can group the preceding typing and mention into one undo step; Chromium and Firefox separate them in the tested flow. Its value-setter fallback still notifies React but cannot guarantee a native undo entry. ## Reporting a problem [#reporting-a-problem] Include a minimal example, React and browser versions, the host type, and exact typing/selection steps. For rich editors, include the adapter and document schema. --- # Caret positioning From a textarea mirror or an editor measurement to the suggestion list's position. Canonical: https://reactmention.com/docs/internals/caret-anchoring By default, the popover consumes a viewport-relative caret rectangle from the editor adapter. How that rectangle is measured depends on the host. A textarea needs a temporary layout mirror; a rich editor uses its own measurement API. ## Measuring a textarea [#measuring-a-textarea] The textarea's internal text layout is not exposed as DOM text nodes that Mention can measure with a `Range`. `getCaretCoordinates()` builds a temporary `div` in `document.body` and asks the browser to lay out the same text. 1. Copy the computed properties that affect layout: dimensions, borders, padding, fonts, spacing, direction, and bidi behavior. Set wrapping on the mirror to preserve line breaks and wrap long words. 2. Put the text before the caret into the mirror, then put the remaining text inside a trailing `span`. The remainder matters: it preserves wrapping when the caret falls inside a word. 3. Read the span's position and add the textarea's border offsets. Use the computed line height, with measured text height or font size as fallbacks. 4. Remove the mirror in a `finally` block, including when measurement fails. The mirror is created and removed for each measurement. It is not a persistent sibling beside the input, and the span contains the suffix rather than being a zero-width cursor. At the end of the value, the span needs a fallback character to produce layout. The implementation uses a period for left-to-right text and a strong right-to-left character for RTL text. In RTL flow it reads the span's right edge. Firefox also has a separate overflow branch to match the textarea's wrapping. ## Translate into viewport coordinates [#translate-into-viewport-coordinates] The textarea adapter combines that local measurement with the textarea's bounding rectangle and subtracts the input's own scroll offsets: ```text x = textarea.left + caret.left - textarea.scrollLeft y = textarea.top + caret.top - textarea.scrollTop width = 0 height = measured line height ``` The result is a `DOMRect` in viewport coordinates. Floating UI then converts that reference into the popup's positioned layout. Scrolling inside a textarea and scrolling its surrounding page are different parts of this calculation. ## Rich editors supply the rectangle [#rich-editors-supply-the-rectangle] `EditorAdapter.getCaretRect()` performs the host-specific measurement. The ProseMirror example uses `view.coordsAtPos()` and returns a zero-width rectangle with the measured line height. Mention does not build a text mirror for that editor. If the adapter returns `null`, the popover falls back to the editor element's bounding rectangle. That provides a positioning fallback, not a precise caret measurement. ## Place and constrain the list [#place-and-constrain-the-list] `Mention.Popover` gives Floating UI a virtual reference whose `contextElement` is the editor. It requests the `placement` prop (`bottom-start` by default), a 4 px offset, flipping when space is constrained, and shifting with 8 px padding. Its maximum height is limited by available space and the `maxHeight` prop, which defaults to 280 px. Pass `anchorRef` to use an element's rectangle instead of the caret, such as the input-group wrapper in the [AI composer](/docs/ai-composer#suggestion-positioning). An omitted or empty ref keeps the default caret reference. `matchAnchorWidth` matches the reference's width, capped by available viewport space; it defaults to false. This changes only the popup geometry: detection and insertion still use the editor's current snapshot. Floating UI's `autoUpdate` handles relevant scroll and resize changes while the reference and popup are mounted. Mention also requests an update when the popup opens or its query changes. The default popup portal is the editor document's body; `container={null}` renders it in place, and an element selects a custom portal container. ## Verify actual geometry [#verify-actual-geometry] Test the [working textarea](/#playground) and your own integration at line endings, wrapped words, scroll boundaries, and with realistic fonts and RTL content. A visible approximation of the mirror cannot establish where the library's popup actually lands. Paths relative to `packages/react`: * `src/text/caret.ts`: temporary mirror and local coordinates. * `src/adapters/textarea.ts`: viewport conversion. * `src/components/Popover.tsx`: virtual reference, collision handling, and portal. * `src/text/caret.browser.test.ts`: browser layout assertions for borders, line height, newlines, RTL and cleanup. Those fixtures cover selected geometry cases, not every font or mixed-direction layout. For integration issues, see [positioning troubleshooting](/docs/troubleshooting#caret-positioning-drifts). --- # Editor ownership How a snapshot, a suggestion session, and a host transaction fit together. Canonical: https://reactmention.com/docs/internals Mention reads the text around a caret, offers suggestions, and asks the editing host to apply a replacement. It does not keep a second document. That boundary is the same for a native textarea and a rich editor. These internals explain the current implementation. For a working integration, start with the [quickstart](/docs) or the [rich-editor guide](/docs/rich-text). ## One document, one owner [#one-document-one-owner] | Concern | Owner | | --------------------------------------------------------- | ----------------- | | Document, selection, formatting, mention nodes | Editing host | | Trigger detection and suggestion requests | Mention | | Highlighting and suggestion selection | Mention | | Applying an edit, serialization, clipboard, undo and redo | Editing host | | Measuring the caret | Editor adapter | | Placing the suggestion list against that measurement | Mention's popover | `Mention.Input` supplies a textarea adapter. A rich editor supplies an `EditorAdapter` through `setEditor()`. Both feed the same core; there is no separate rich-document engine inside Mention. ## Read one editable region [#read-one-editable-region] An adapter's `read()` returns text, a collapsed caret, and an optional region key: ```ts { text: "Hello @al", caret: 9, key: paragraphId } ``` The caret and replacement range use UTF-16 offsets relative to that region. The key distinguishes regions with identical text, such as two paragraphs. A rich editor must map these offsets to its own document positions. Return `null` when Mention should not offer an edit: for an expanded selection, composition, read-only content, or an unsupported region. The host must enforce these conditions in its adapter. The built-in textarea path also handles composition through its input event handlers. The ProseMirror example reads only the current text block, uses a placeholder for each inline atom, and uses the block's start position as its key. This preserves the example's offset mapping without flattening the document. The Lexical example maps UTF-16 across formatted text nodes and uses the block’s node key. Both represent existing inline atoms as `U+FFFC`, which stops trigger detection. These are concrete mappings; other editors can use different position units. ## From a snapshot to an edit [#from-a-snapshot-to-an-edit] 1. The host calls `refresh()` after a document or selection change. `Mention.Input` wires this into normal textarea events. 2. Mention scans backward from the caret for an eligible trigger and records the snapshot, trigger, and query as a session. 3. The active channel provides suggestions. The highlight belongs to that session. 4. Before inserting, Mention reads the host again. The text, caret, and region key must still match, and the chosen item must belong to the current successful results. 5. Mention calls `replace(edit, item, meta)`. Only an applied replacement produces the selection callback; returning `false` rejects the edit. For the snapshot above, the replacement range is `[6, 9)`: `@al`. Text after the caret stays outside the replacement. By default, the insertion is the trigger plus the item's label, with a space added unless the insertion ends in whitespace or the suffix starts with whitespace. A rich editor may create a mention node from `item` instead of inserting `edit.text`. Its transaction decides the node, separator, resulting selection, and undo group. Mention does not repair a host's incorrect offset mapping or transaction. ## Textarea history [#textarea-history] The textarea adapter first attempts native `insertText` and checks the resulting value. If that path does not apply the expected text, it uses the native value setter, places the caret, and dispatches an input event so React receives the edit. The native path supports browser history. The setter fallback does not establish equivalent undo behavior, and undo grouping remains browser-defined. Rich-editor history belongs to the host's transaction system. ## Follow the implementation [#follow-the-implementation] Paths are relative to `packages/react`: * `src/adapters/types.ts`: snapshot and replacement contract. * `src/adapters/textarea.ts`: native input measurement and edits. * `src/hooks/useMentionCore.ts`: session, host registration, refresh and commit guards. * `examples/ProseMirror.tsx` and `examples/Lexical.tsx`: executable rich-editor adapters. * `src/components/editing.test.tsx`: controlled input and stale-selection checks. * `e2e/editor.spec.ts` and `e2e/lexical.spec.ts`: mention nodes, formatting, block boundaries, clipboard data, and history. Continue with [request lifecycle](/docs/internals/request-lifecycle), [caret positioning](/docs/internals/caret-anchoring), or [focus and ARIA](/docs/internals/interaction). --- # Focus and ARIA Native textbox semantics, suggestion relationships, focus, and keyboard handling. Canonical: https://reactmention.com/docs/internals/interaction Mention keeps DOM focus in the editor while a suggestion is highlighted. The implementation connects that editor to a listbox and its active option. These are observable DOM relationships; they do not establish what every screen reader announces. The host supplies its text-editing semantics. A textarea keeps its implicit `textbox` role; the ProseMirror example supplies `role="textbox"` and `aria-multiline="true"` on its editable host. Mention adds the relationships to suggestions. It does not assign a combobox role or `aria-expanded` to a textarea. See the [accessibility guide](/docs/accessibility) for integration checks and manual verification limits. ## Attributes follow the current session [#attributes-follow-the-current-session] `getEditorProps()` supplies these attributes; `Mention.Input` applies them to its textarea. A rich-editor integration must apply and update them on its own host. | Attribute | Current behavior | | ----------------------- | ------------------------------------------------------------ | | `aria-haspopup` | `listbox` | | `aria-autocomplete` | `list` | | `aria-controls` | Listbox ID while open; omitted while closed | | `aria-activedescendant` | Highlighted option ID when an item exists; otherwise omitted | The popup receives `role="listbox"`, its generated ID, and `aria-busy` while loading. Each rendered option receives its generated ID, `role="option"`, and `aria-selected`. The first result is highlighted by default in a new session. An empty, loading, or failed result set has no active option. A failed request hides the popup and omits `aria-controls`, while preserving the failed session for an explicit retry. The [async search example](/docs/recipes/async-items) supplies a persistent status region and a recovery button outside the listbox. The application owns their wording; the library does not insert extra announcements during option navigation. Render the list and its items with the matching props; conditionally removing the active item can break the ID relationship. Supply an accessible name for both the editor and the listbox. The core does not invent those labels. ## Focus stays with the host [#focus-stays-with-the-host] Options are not additional tab stops by default. Primary mouse-down keeps focus in the host; a completed click or tap attempts insertion. Pressing an option and releasing outside it cancels selection, and scrolling the list by touch does not insert an item. Consumer pointer-down, mouse-down, and click handlers run first and can prevent selection. Mouse movement can update the highlight; a stationary pointer does not steal the initial highlight when the popup appears beneath it. Arrow navigation changes the highlight and reveals the active option by scrolling the listbox itself. It does not focus the option or scroll ancestor containers such as the page. [WAI-ARIA explicitly allows this relationship for a textbox](https://www.w3.org/TR/wai-aria-1.2/#aria-activedescendant): the focused textbox controls a listbox, and its active descendant is an option owned by that listbox. ## Keys are handled conditionally [#keys-are-handled-conditionally] Unmodified ArrowUp and ArrowDown wrap through available items. Enter or Tab commits only when there is a current item and the host snapshot is unchanged. A successful Tab commit prevents the browser's normal focus move; a later Tab without a selectable result can move focus normally. Escape dismisses the session without editing the text. Refreshing the unchanged snapshot keeps it dismissed. For default channels, a changed text, caret, or region clears dismissal. With `allowSpaces`, continuing forward from the same dismissed query also stays closed; a new trigger, an earlier edit, or explicit opening can make it eligible again. This prevents typing after a selected full name from reopening its old trigger. The core leaves modified keys, already-prevented events, and composition keystrokes to the host. Consumer textarea key handlers run first and can prevent Mention's handling. Blur and outside pointer interaction close the session. The textarea integration pauses refresh during composition and rescans on composition end. A rich editor must expose composition state through its adapter and refresh when composition finishes. Synthetic event checks cannot reproduce an operating system's candidate window. ## Evidence and remaining work [#evidence-and-remaining-work] Paths relative to `packages/react`: * `src/hooks/useMentionCore.ts`: attributes, event handling, focus dismissal, and commit guards. * `src/hooks/mouse-moving-guard.ts`: recent mouse movement tracking. * `e2e/contract.spec.ts`: runtime attribute relationships and keyboard focus. * `e2e/pointer.spec.ts`: completed clicks, drag cancellation, editor focus, and emulated touch and pen input. * `e2e/a11y.spec.ts`: unfiltered automated checks, with the long-list keyboard-scroll heuristic and body-portal landmark finding tracked explicitly. * `e2e/editing.spec.ts`: keyboard access to options beyond the visible scroll boundary. * `e2e/examples.spec.ts`: the actual composer and form, including validation, submission, reset, and focus. A DOM inspector or an axe pass cannot prove spoken output, reading order, or real IME usability. The current implementation still needs manual NVDA, JAWS, VoiceOver, and TalkBack verification in representative applications. Claims from the original prototype do not validate this implementation. --- # Request lifecycle Why a late response cannot become a suggestion for a newer editing session. Canonical: https://reactmention.com/docs/internals/request-lifecycle A query string is not enough to identify a request. Two paragraphs can contain the same `@al`, and a user can move the caret while a request is pending. Mention ties results to an editing session, then checks the host again before applying an edit. ## Detect the active query [#detect-the-active-query] The detector scans backward from the collapsed caret. It accepts a configured trigger at the start of the region, after whitespace, or after a character from a supported non-whitespace-segmented script. It stops at an ineligible trigger, an editor atom (`U+FFFC`), or whitespace other than horizontal space separators explicitly allowed by the channel. `foo@bar` does not open a session. Triggers are one non-whitespace UTF-16 character. Queries can contain horizontal space separators when `allowSpaces` is true; tabs and line breaks always end them. Script boundaries are a character-level heuristic; the [international input guide](/docs/recipes/i18n) documents the supported scripts and the Han-plus-email ambiguity. `refresh()` keeps the same session object when its text, caret, key, trigger, and query are unchanged. A meaningful change creates a new session. Closing remembers the dismissed snapshot so an unchanged refresh does not immediately reopen it. For `allowSpaces` channels, forward typing that retains the dismissed prefix and trigger position also stays closed; `setOpen(true)` clears that dismissal and rescans. If the current request failed, explicit opening first discards that failed session so the same snapshot gets a fresh request identity. Ordinary refreshes do not retry a failure, and reopening a pending or successful session does not duplicate its request. ## Arrays and fetchers take different paths [#arrays-and-fetchers-take-different-paths] | Item source | Current behavior | | ----------- | ------------------------------------------------------------------------------------------------------------------------------ | | Array | Filter during render with the channel’s `filter`, or a case-insensitive label substring match by default; status is `success`. | | Fetcher | Wait for the debounce delay, then call the fetcher with the query and an `AbortSignal`. | The default async debounce is 150 ms. A session reports `loading` during that delay and while the request is pending. A resolved empty array is a successful empty result; a thrown or rejected request reports `error`. A failed session reports `open: false`: its popup and editor suggestion relationships disappear, but its query and failure remain available for explicit retry. There is no built-in cache, ranking, pagination, or automatic retry. See [async items](/docs/recipes/async-items) for a typed fetcher and error UI. ## Cancel work and reject obsolete results [#cancel-work-and-reject-obsolete-results] Each request effect creates an `AbortController`. Cleanup clears the debounce timer and aborts the controller when its dependencies change, the session closes, or the component unmounts. Forward the signal to your network client to stop unnecessary work. Cancellation is only one guard. The render path exposes a result only if its session object and fetcher still match and its signal is not aborted. Until then, it returns an empty list with `loading` status. This hides old results before the next request effect runs. A late response from an aborted request is also ignored, even if the fetcher did not cooperate with cancellation. For example: | Event | Visible result | | ------------------------------------ | ------------------------ | | Type `@a`; request A starts | Loading, no old items | | Type `l`; request B starts for `@al` | Loading; A is obsolete | | B resolves | B's items | | A resolves late | B's items remain current | Keep the fetcher reference stable when its behavior is unchanged. A new function is a new source and restarts the request. ## Check again at insertion [#check-again-at-insertion] `commit(item)` requires a current session, successful results containing that item, and an unchanged host snapshot. It also refuses to run during composition. `getKey()` identifies rendered items; it is not the membership check used by `commit()`. Pass the actual result item, not a reconstructed object with the same ID. The key handler checks the snapshot before navigating or selecting. If a selection moved without the expected refresh, it closes the stale session instead of replacing unrelated text. Hosts still need to call `refresh()` promptly: the guard prevents a stale edit, but does not update a host that never reports its changes. ## Follow the implementation [#follow-the-implementation] Paths are relative to `packages/react`: * `src/state/find-active-mention.ts`: trigger and boundary scan. * `src/hooks/use-channel-query.ts`: filtering, debounce, cancellation, and result ownership. * `src/hooks/useMentionCore.ts`: session identity and insertion checks. * `src/components/editing.test.tsx`: reversed request ordering, ignored cancellation, channel switches, failure, unmount cleanup, and moved selections. The tests exercise the real core rather than a separate request simulation. --- # Async search Fetch suggestions with debouncing, cancellation, loading, empty, failure, and same-query retry. Canonical: https://reactmention.com/docs/recipes/async-items Pass a fetcher as `items`: `(query, signal) => Promise`. The fetcher supplies the search results; Mention does not apply the array filter to its response. ## Try failure and recovery [#try-failure-and-recovery] Type `@al`. This local example delays its response and deliberately fails the first completed search. Choose **Retry search** to repeat the same query without editing your message, then select Alice. Search `@nobody` to see an empty result.
The status message stays outside the listbox and announces loading, the result count, an empty response, or a failure. A failed search hides the popup and removes its suggestion relationships from the input. The recovery button remains available when focus leaves the editor. ## Use the complete example [#use-the-complete-example] The running example, this source block, and the browser fixture use the same component. Replace the local delay, sample filtering, and deliberate failure with your application's search request; keep the cancellation and recovery behavior. ```tsx title="AsyncSearch.tsx" "use client"; import { Mention, type MentionImperativeHandle, useMentionContext, } from "@danielivanov/mention"; import { useCallback, useId, useRef, useState } from "react"; import "@danielivanov/mention/styles.css"; type Person = { id: string; name: string }; const people: Person[] = [ { id: "alice", name: "Alice" }, { id: "bob", name: "Bob" }, ]; function SearchFeedback({ id, failure, onRetry, }: { id: string; failure: boolean; onRetry: () => void; }) { const { status, items } = useMentionContext(); const loading = status === "loading"; return (

{loading ? "Searching people…" : failure ? "Could not load people. Try again." : status === "success" ? items.length > 0 ? `${items.length} ${items.length === 1 ? "person" : "people"} found. Use the arrow keys to choose.` : "No people found. Try another name." : ""}

{failure && !loading && ( )}
); } export function AsyncSearch() { const id = useId(); const inputRef = useRef(null); const mentionRef = useRef>(null); const failNext = useRef(true); // Keep recovery available when moving focus from the editor to Retry. const [failedSearch, setFailedSearch] = useState<{ text: string; caret: number; } | null>(null); const searchPeople = useCallback( async (query: string, signal: AbortSignal) => { setFailedSearch(null); const input = inputRef.current; const snapshot = input ? { text: input.value, caret: input.selectionStart } : null; try { // Local demo only: delay the response and fail the first completed request. // Replace this block with your search endpoint, forwarding its signal. await new Promise((resolve, reject) => { const timer = setTimeout(resolve, 400); signal.addEventListener( "abort", () => { clearTimeout(timer); reject(signal.reason); }, { once: true }, ); }); signal.throwIfAborted(); if (failNext.current) { failNext.current = false; throw new Error("Simulated search failure"); } return people.filter((person) => person.name.toLowerCase().includes(query.toLowerCase()), ); } catch (error) { if (!signal.aborted) setFailedSearch(snapshot); throw error; } }, [], ); return ( items={searchPeople} getKey={(person) => person.id} getLabel={(person) => person.name} handleRef={mentionRef} >

Type @ to search Alice or Bob. The first search simulates a failure; retry to search again without editing your message.

setFailedSearch(null)} onSelect={(event) => { const input = event.currentTarget; setFailedSearch((previous) => previous?.text === input.value && previous.caret === input.selectionStart && input.selectionStart === input.selectionEnd ? previous : null, ); }} /> > {(person) => ( {person.name} )} { inputRef.current?.focus(); mentionRef.current?.open(); }} /> ); } ``` `useMentionContext()` reads the existing Root session. A standalone `useMention()` call creates a separate session and cannot read that Root's status. ## Connect a search endpoint [#connect-a-search-endpoint] Forward the supplied signal to `fetch`, check the response status, and validate the returned data before using it. For an endpoint returning `{ id: string, name: string }[]`, replace the demo search block with: ```tsx title="Inside searchPeople" const response = await fetch(`/api/people?q=${encodeURIComponent(query)}`, { signal, }); if (!response.ok) throw new Error(`Search failed: ${response.status}`); const data: unknown = await response.json(); if ( !Array.isArray(data) || !data.every( (person) => typeof person === "object" && person !== null && typeof person.id === "string" && typeof person.name === "string", ) ) { throw new Error("Unexpected search response"); } return data as Person[]; ``` Implement this endpoint in your application or replace its URL and validation with your API's contract. The running example makes no network requests. ## Set the debounce delay [#set-the-debounce-delay] Async requests debounce for **150 ms** by default. Set `debounceMs={300}` on Root to wait longer, or `debounceMs={0}` to start immediately. This delay applies only to fetchers, not synchronous item arrays. ## Cancel unnecessary work [#cancel-unnecessary-work] When the query, channel, or fetcher changes, or the session closes, Mention aborts the previous request. Forwarding the signal lets the network client stop work that is no longer needed. The core also discards late results when a fetcher ignores cancellation. It hides previous results as soon as the query changes and will not commit an obsolete item. If the fetcher updates application feedback, check `signal.aborted` before recording its failure too, as the example does. Keep the fetcher reference stable when its behavior has not changed: declare it outside the component, or use `useCallback` with its real dependencies. A new fetcher reference starts a new request. ## Retry without editing [#retry-without-editing] Call the handle's `open()` or the hook's `setOpen(true)` to retry a failed search at the current caret. It creates a new request session, leaving the text unchanged. Calling it during loading or after a successful search does not duplicate that request. Ordinary `refresh()` calls only rescan the editor; they do not retry an unchanged failed query. The example retains its failure message across blur because moving to **Retry search** dismisses the editing session. The button restores input focus before calling `open()`, so the response is immediately ready for arrow-key selection. Changing the text or selection clears the old failure: recovery feedback belongs to that text and caret, too. The library supplies no automatic retries, cache, or retry control. ## Show each outcome [#show-each-outcome] * Keep one persistent `role="status"` region outside the listbox. Update its text for loading, result count, no matches, and failure; verify actual announcements with your target screen readers. * `Mention.Loading` renders while a request is pending, including its debounce delay. * `Mention.Empty` renders after a successful query with no results. * `status === "error"` identifies a rejected active request. Its popup is hidden, while the failed session remains available for explicit retry. * Put interactive recovery controls outside the listbox, which is reserved for suggestions. Return `[]` for no matches. Throw for request, response, or parsing failures. These are different outcomes and should have different feedback. Synchronous arrays may be created during render. The core derives their filtered results directly; fresh array references are not prohibited. Memoize expensive data preparation when your application benefits from it. ## Verify request ordering [#verify-request-ordering] Make one query slow and a later query fast. Confirm that the fast query's results remain current when the earlier request finally resolves, including when a test fetcher deliberately ignores the signal. Also check a failed request, same-query retry by pointer and keyboard, a successful empty response, and editing while a retry is pending. --- # Controlled forms Validate, submit, and reset a real form while Mention preserves ordinary textarea behavior. Canonical: https://reactmention.com/docs/recipes/controlled-value Your form owns the text. Mention writes selections through the input's normal change handler, so typed text and inserted mentions follow the same state and submission path. ## Try the form [#try-the-form] Submit an empty message to see validation. Then type `@al`, select Alice, and submit again. The receipt shows the actual `FormData` value. Reset clears the text, validation, and receipt, and returns focus to the input.
This example keeps submissions on the page. In your application, send the validated form data through your existing submission handler. ## Use the complete example [#use-the-complete-example] The running form, this source block, and the browser test fixture use the same component. No form library or Mention-specific form wrapper is needed. Style the native form controls with your application's design system. ```tsx title="MessageForm.tsx" "use client"; import { Mention, type MentionImperativeHandle } from "@danielivanov/mention"; import { type FormEvent, useId, useRef, useState } from "react"; import { flushSync } from "react-dom"; import "@danielivanov/mention/styles.css"; type Person = { id: string; name: string }; const people: Person[] = [ { id: "alice", name: "Alice" }, { id: "bob", name: "Bob" }, ]; export function MessageForm() { const id = useId(); const inputRef = useRef(null); const mentionRef = useRef>(null); const [value, setValue] = useState(""); const [error, setError] = useState(null); const [submitted, setSubmitted] = useState(null); function submit(event: FormEvent) { event.preventDefault(); if (!value.trim()) { setError("Enter a message before submitting."); setSubmitted(null); inputRef.current?.focus(); return; } setError(null); const data = new FormData(event.currentTarget); setSubmitted(String(data.get("message"))); } function reset(event: FormEvent) { event.preventDefault(); flushSync(() => { setValue(""); setError(null); setSubmitted(null); }); inputRef.current?.focus(); mentionRef.current?.close(); } return (
items={people} getKey={(person) => person.id} getLabel={(person) => person.name} handleRef={mentionRef} >

Type @ to mention Alice or Bob. A message is required.

{ const next = event.currentTarget.value; setValue(next); setSubmitted(null); if (next.trim()) setError(null); }} aria-invalid={error !== null} aria-describedby={`${id}-hint${error ? ` ${id}-error` : ""}`} /> {error && ( )} > {(person) => ( {person.name} )} No people found. Try another name.

{submitted !== null ? `Submitted locally: ${submitted}` : "This demo keeps submissions on this page."}

); } ``` ## Validation and focus [#validation-and-focus] The form uses `noValidate` to display its own required-field error. It checks trimmed text on submission, connects the error to the textarea with `aria-describedby`, sets `aria-invalid`, and focuses the invalid field through its ordinary React ref. Correcting the value clears the error. Enter commits a highlighted suggestion instead of submitting the form. Outside suggestion selection, Enter retains its normal multiline-input behavior. The submit button follows the usual form path. With a form library, pass its `value`, change handler, blur handler, name, and ref to `Mention.Input`. Do not treat Root's `onSelect` callback as a replacement for the field's change handler: it only reports completed mention insertions. ## Reset controlled state [#reset-controlled-state] Reset updates React state explicitly. The example uses `flushSync` so the cleared value reaches the textarea before the imperative focus and `close()` calls. Closing then records the cleared value, allowing the same query to reopen even when pasted back in one event. This also covers a programmatic `form.reset()` while the popup is open. When adapting this to an existing form, use its reset operation for the value, touched state, and errors, and close any active mention session. A programmatic value change is not a native input event. ## Preserve the document model [#preserve-the-document-model] Submission contains plain text. An `onSelect` callback is a notification of insertion, not an inventory of mentions still present: users can delete or replace text afterward. If mentions need stable identities and round-trip serialization, store them in an editor document using [an editor adapter](/docs/rich-text). The standalone hook uses the same form props: ```tsx title="Standalone hook"