@mentionly/react
v2.0.0
Published
React hook for the mentionly contenteditable mention input
Maintainers
Readme
@mentionly/react
Headless React hook for mentionly: a contenteditable
mention input for AI chat scenarios, with atomic mention entities, async and paginated data
sources, IME handling and serialization.
useMention() wraps the framework-agnostic
@mentionly/core engine in
useSyncExternalStore, reads its immutable snapshot, and lets the core own the editor DOM and its
events. There are no published components — you render the editor and the dropdown.
React (>=18) is a peer dependency; @mentionly/core is installed automatically.
For AI agents: this package ships
llms.txt(node_modules/@mentionly/react/llms.txt) with setup and integration rules for its installed version. Site-wide docs: llms.txt, llms-full.txt.
Install
npm install @mentionly/reactA complete, copyable component (keyboard submit, scroll-into-view, pagination, error state) lives
in
examples/react/src/MentionInput.tsx.
Usage
import { useMemo } from 'react'
import { useMention } from '@mentionly/react'
import type { MentionTrigger, Part } from '@mentionly/react'
const USERS = [
{ id: 'u1', label: 'Alice' },
{ id: 'u2', label: 'Bob' },
]
export function Composer({ onSubmit }: { onSubmit: (parts: Part[]) => void }) {
// ⚠ `triggers` must keep a stable reference: a module-level constant or useMemo().
// A new array on every render makes the hook call setOptions(), which closes the popup
// and invalidates in-flight requests. Development builds warn after 3 such renders.
const triggers = useMemo<MentionTrigger[]>(
() => [{ char: '@', items: USERS, toData: (item) => ({ kind: 'user', userId: item.id }) }],
[],
)
const { ref, state, ids, core, select, getParts, clear } = useMention({ triggers })
return (
<>
{/* core owns everything inside this element — do not render React children into it */}
<div ref={ref} contentEditable data-placeholder="Type @ to mention" />
{state.isOpen && (
<ul id={ids.listbox} role="listbox">
{state.filteredItems.map((item, index) => (
<li
key={item.id}
id={ids.option(index)}
role="option"
aria-selected={index === state.activeIndex}
// preventDefault keeps focus in the editor, otherwise blur closes the list
onMouseDown={(e) => e.preventDefault()}
onClick={() => select(item)}
>
{item.label}
</li>
))}
</ul>
)}
<button
type="button"
disabled={core.getState().isEmpty}
onClick={() => {
onSubmit(getParts())
clear()
}}
>
Send
</button>
</>
)
}Building your own list
The list UI is yours, so a few details the Vue component handles internally are your job:
Select on click without losing focus. Call
preventDefault()on the option'smousedown(then select inclick, or select directly inmousedown). Otherwise the editor blurs first, the core closes the list on blur, and the click selects nothing.Fill the first page. Paginated sources load the next page when the list scrolls near its bottom. If the first page does not overflow the list, it can never scroll, so call
loadMore()yourself whenhasMoreis true and the list'sscrollHeight <= clientHeight(or render a "Load more" button).Check
nativeEvent.defaultPreventedin your ownonKeyDown. The core handles Enter / arrows on the native event (for example Enter selects the active item while the list is open). React's synthetice.defaultPreventeddoes not reflect that, so a submit-on-Enter handler that checks it would submit right after the selection. Reade.nativeEvent.defaultPreventedinstead.
The examples in the repository implement all of these.
API
useMention(options: MentionCoreOptions)
| Member | Description |
|--------|-------------|
| ref | Callback ref for the editor element. Attaches on mount, detaches on unmount (StrictMode-safe) |
| state | The core snapshot: isOpen, filteredItems, activeIndex, query, activeTrigger, loading, error, loadingMore, hasMore, popupPosition, isEmpty |
| ids | { listbox, option(index) } — required on the dropdown root and options |
| core | The underlying MentionCore instance |
| select(item) | Select a candidate |
| insertMention(payload, opts?) | Insert an atomic mention node programmatically |
| loadMore() | Append the next page (paginated function sources) |
| close() | Close the popup and abort in-flight requests |
| getParts() | Read the editor into Part[] |
| getPlainText() | Editor content as plain text |
| clear() | Empty the editor |
| setContent(parts) | Restore from Part[] |
| focus() | Focus the editor, caret at the end |
Options are diffed per render (triggers, insertSpaceAfter, popupMode,
popupScrollBehavior) and only written back to the core when they actually change.
Things to know
- Keep
triggersreferentially stable. A new array on every render triggerssetOptions(), which closes the list and aborts in-flight requests. Use a module-level constant oruseMemo(); development builds warn once after 3 consecutive renders with a changing reference. - Never render React children into the editor element. The core mutates that DOM directly
(mention spans, caret). For a placeholder use
data-placeholderplus:empty::beforein CSS. - Don't implement keyboard navigation. ↑ / ↓ / Enter / Tab / Escape and IME composition are
handled by the core; read
state.isOpen/core.getState()in your ownkeydownonly to decide when Enter should submit.
Content format
type Part<T = unknown> = TextPart | MentionPart<T>
interface TextPart { type: 'text'; text: string }
interface MentionPart<T = unknown> {
type: 'mention'
trigger: string
id: string
label: string
data?: T
}toData(item) runs at selection time and its result is persisted on the mention node, so
getParts() can return it as data later without keeping the original item around.
