better-mentions
v0.0.1
Published
The definitive modern mentions & autocomplete combobox for React. Headless, strongly typed, WAI-ARIA compliant.
Maintainers
Readme
better-mentions
The modern, type-safe, headless mentions & autocomplete combobox for React.
Drop-in replacement for the @ mention UX of Slack, GitHub and Notion — with
strong TypeScript inference, WAI-ARIA Combobox compliance and pixel-perfect
anchoring to the caret. No jQuery. No legacy baggage.
Headless by design.
better-mentionsships zero styles. You bring your own markup and CSS (or a styling library) and keep full control.
Motivation
Why another mentions library? The classics served us well, but they were built for a different era:
| | At.js | Tribute.js | better-mentions |
| --- | --- | --- | --- |
| Dependencies | jQuery | jQuery-like assumptions | None (React only) |
| Typing | None | Weak / any-heavy | Full generic inference with TS 7 |
| React story | Manual glue | Refs + imperative hacks | First-class React 18 & 19 |
| Accessibility | None | Partial | WAI-ARIA Combobox out of the box |
| Positioning | Relative div hacks | Fixed offsets | Caret-anchored (live Range rects) |
| Styling | Prescriptive CSS | Prescriptive CSS | Headless — you own the UI |
better-mentions treats mentions as a headless state machine: it gives you
the query, the filtered items, the active index and the ARIA wiring — and
nothing else. The DOM, the styles and the behavior are yours.
Features
- 🧠 Strong typing — items are generic (
MentionEditor<T>), with automatic inference forgetLabel,filterandonItemSelect. - 🎨 Headless — bring your own UI; the components render no styles.
- ♿ WAI-ARIA —
combobox,listbox,option,aria-activedescendant,aria-expanded,aria-controls. - ⌨️ Keyboard navigation —
ArrowUp/ArrowDown/Home/End/Enter/Escape/Tab. - 🎯 Caret anchoring — the listbox is positioned from the live caret rect.
- 🔍 Smart triggers — custom triggers (
@,#,:…), word-boundary guards andallowSpace/maxLengthoptions. - ⚛️ React 18 & 19 — the same code runs on both majors.
- 🧪 Tested — Vitest + React Testing Library, plus Storybook a11y addon.
Installation
npm install better-mentionspnpm add better-mentionsUsage
import { MentionEditor, MentionList, MentionItem } from "better-mentions";
interface User {
id: number;
label: string;
}
const users: User[] = [
{ id: 1, label: "Vitor" },
{ id: 2, label: "Maria" },
{ id: 3, label: "Marcos" },
];
export function CommentBox() {
return (
<MentionEditor
items={users}
placeholder="Type @ to mention someone…"
onItemSelect={(user) => console.log("mentioned", user)}
onChange={(html, plainText) => console.log(plainText)}
>
<MentionList className="rounded-lg border bg-white shadow-lg" />
</MentionEditor>
);
}That's it. MentionList renders a MentionItem (with the matching substring
highlighted) for every filtered item.
Labels and values
Every inserted mention becomes a <span data-mention data-id="…" data-value="…"
class="mention">@Label</span>. By default data-value is the item id; pass
getValue to store a different payload (e.g. a slug or a UUID):
<MentionEditor
items={users}
getValue={(user) => user.slug}
onChange={(html, plainText) => console.log(html)}
>
<MentionList />
</MentionEditor>Deserialize mentions back from the saved HTML with parseMentions:
import { parseMentions } from "better-mentions";
const mentions = parseMentions(html);
// [{ id: "1", value: "my-slug", label: "Vitor" }, …]parseMentions returns { id, value, label }[] where value falls back to
id when the data-value attribute is absent. Because the markup is plain
HTML attributes, it round-trips through any editor, server sanitizer or
database column without extra state.
Custom rows
import { MentionEditor, MentionList, useMentions } from "better-mentions";
function UserRow({ user, active, index }) {
// Row ids must match getOptionId() so aria-activedescendant stays in sync.
const { getOptionId } = useMentions<User>();
return (
<li
role="option"
id={getOptionId(index)}
aria-selected={active}
style={{ background: active ? "#eef2ff" : undefined }}
>
<img src={user.avatar} alt="" /> {user.label}
</li>
);
}
<MentionList>
{(user, { active, index }) => <UserRow user={user} active={active} index={index} />}
</MentionList>API
| Component | Purpose |
| --- | --- |
| <MentionEditor> | Editable surface (contenteditable) + ARIA combobox wiring |
| <MentionList> | Caret-anchored listbox, renders a row per filtered item |
| <MentionItem> | Default row (option) with match highlighting |
| useMentions() | Access query, items, activeIndex, selectItem, … |
| useMentionState() | Headless state machine for custom editors |
| <MentionProvider> | Headless provider that drives an editor through a MentionSurface |
Editor integrations
better-mentions/adapters ships surfaces for TinyMCE, Quill and
Froala plus a generic createContentEditableSurface. A MentionSurface
implements a small contract (getTextBeforeCaret, insertMention,
getCaretRect, serialize, focus, onContentChange, onSelectionChange);
the adapters are structurally typed, so the editors are optional
peerDependencies and the library itself never loads them.
pnpm add better-mentions quill tinymceQuill
import Quill from "quill";
import { createQuillSurface } from "better-mentions/adapters";
import { MentionList, MentionProvider } from "better-mentions";
const Inline = Quill.import("blots/inline");
class MentionBlot extends Inline {
static blotName = "mention";
static tagName = "span";
static attributes = ["data-mention", "data-id", "data-value"];
}
Quill.register("blots/mention", MentionBlot);
function CommentBox() {
const hostRef = useRef<HTMLDivElement | null>(null);
const [surface, setSurface] = useState<MentionSurface | null>(null);
useEffect(() => {
const quill = new Quill(hostRef.current!, { theme: "snow" });
setSurface(createQuillSurface(quill));
}, []);
if (!surface) return <div ref={hostRef} />;
return (
<>
<div ref={hostRef} />
<MentionProvider surface={surface} items={users}>
<MentionList />
</MentionProvider>
</>
);
}TinyMCE
TinyMCE runs inside an iframe, so the listbox is portaled into the editor's
document via the portal prop:
import tinymce from "tinymce/tinymce";
import { createTinyMceSurface } from "better-mentions/adapters";
import { MentionList, MentionProvider } from "better-mentions";
// setup(ed) -> setSurface(createTinyMceSurface(ed)); docRef.current = ed.getDoc()
<MentionProvider surface={surface} items={users}>
<MentionList portal={() => docRef.current?.body ?? null} />
</MentionProvider>Froala
import { createFroalaSurface } from "better-mentions/adapters";
// const surface = createFroalaSurface($("#editor").data("froala-editor"));Custom surfaces
Any editor can be integrated by implementing the MentionSurface interface
yourself; the four built-in adapters are small reference implementations.
Accessibility
The combobox follows the
WAI-ARIA Combobox pattern:
the editor exposes role="combobox", aria-autocomplete="list",
aria-expanded and aria-activedescendant; the list exposes
role="listbox" and each row role="option" with aria-selected. Full
keyboard support is included — screen readers announce the active option as
you navigate.
Contributing
pnpm install
pnpm storybook # dev playground
pnpm test:run # unit + storybook tests
pnpm build # ESM + CJS + .d.ts
pnpm lintLicense
MIT
