Headless Usage
Full control over rendering. You own the DOM, the library owns the logic.
When to Use
- You want to customize the dropdown layout and positioning
- You need to integrate mentions into an existing UI
- You need complete control over item rendering
For most headless use cases, the compound component API gives you layout control while the library handles the editor internals. Use the raw hook/composable only when you need to control the contenteditable element itself. (Compound components are React / Vue / Svelte only — in Angular, use <sa-mentions> for the batteries-included path, or the bridge below for full control.)
Compound Components (Recommended)
import { Mentions } from "@skyastrall/mentions-react";
const users = [
{ id: "1", label: "Alice" },
{ id: "2", label: "Bob" },
];
function Editor() {
return (
<Mentions triggers={[{ char: "@", data: users }]}>
<Mentions.Editor placeholder="Type @..." />
<Mentions.Portal>
<Mentions.List>
<Mentions.Item render={({ item, highlighted }) => (
<div style={{ fontWeight: highlighted ? 600 : 400 }}>
{item.label}
</div>
)} />
</Mentions.List>
</Mentions.Portal>
</Mentions>
);
} <script setup>
import {
Mentions, MentionsEditor, MentionsPortal,
MentionsList, MentionsItem,
} from "@skyastrall/mentions-vue";
const users = [
{ id: "1", label: "Alice" },
{ id: "2", label: "Bob" },
];
</script>
<template>
<Mentions :triggers="[{ char: '@', data: users }]">
<MentionsEditor placeholder="Type @..." />
<MentionsPortal>
<MentionsList>
<MentionsItem v-slot="{ item, highlighted }">
<div :style="{ fontWeight: highlighted ? 600 : 400 }">
{{ item.label }}
</div>
</MentionsItem>
</MentionsList>
</MentionsPortal>
</Mentions>
</template> <script>
import {
Mentions, MentionsEditor, MentionsPortal,
MentionsList, MentionsItem,
} from "@skyastrall/mentions-svelte";
const users = [
{ id: "1", label: "Alice" },
{ id: "2", label: "Bob" },
];
</script>
<Mentions triggers={[{ char: "@", data: users }]}>
<MentionsEditor placeholder="Type @..." />
<MentionsPortal>
<MentionsList>
<MentionsItem>
{#snippet children({ item, highlighted })}
<div style:font-weight={highlighted ? 600 : 400}>
{item.label}
</div>
{/snippet}
</MentionsItem>
</MentionsList>
</MentionsPortal>
</Mentions> Raw Hook / Composable (Full Control)
When you need to render the contenteditable div yourself. Angular has no hook — drive the MentionsControllerBridge directly (it wraps the same core controller that <sa-mentions> uses internally; the DOM-read helpers live in @skyastrall/mentions-core):
import { useMentions } from "@skyastrall/mentions-react";
function CustomEditor() {
const {
editorRef, inputProps, listProps, getItemProps,
isOpen, items, highlightedIndex, caretPosition,
handleInput,
} = useMentions({
triggers: [{ char: "@", data: users }],
});
return (
<div data-mentions="" style={{ position: "relative" }}>
<div
ref={editorRef}
contentEditable="plaintext-only"
onInput={handleInput}
data-mentions-editor=""
{...inputProps}
/>
{isOpen && (
<ul {...listProps} style={{
position: "fixed",
top: (caretPosition?.top ?? 0) + (caretPosition?.height ?? 0) + 4,
left: caretPosition?.left ?? 0,
}}>
{items.map((item, i) => (
<li key={item.id} {...getItemProps(i)}>
{item.label}
</li>
))}
</ul>
)}
</div>
);
} <script setup>
import { useMentions } from "@skyastrall/mentions-vue";
const users = [
{ id: "1", label: "Alice" },
{ id: "2", label: "Bob" },
];
const {
editorRef, state, aria, isOpen, items,
highlightedIndex, caretPosition,
handleInput, handleKeyDown, handleBlur,
handleCompositionStart, handleCompositionEnd,
performInsertion,
} = useMentions({
triggers: [{ char: "@", data: users }],
});
</script>
<template>
<div data-mentions="" style="position: relative">
<div
ref="editorRef"
contenteditable="plaintext-only"
data-mentions-editor=""
v-bind="aria.inputProps"
@input="handleInput"
@keydown="handleKeyDown"
@blur="handleBlur"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
<ul v-if="isOpen" v-bind="aria.listProps" :style="{
position: 'fixed',
top: (caretPosition?.top ?? 0) + (caretPosition?.height ?? 0) + 4 + 'px',
left: (caretPosition?.left ?? 0) + 'px',
}">
<li
v-for="(item, i) in items"
:key="item.id"
v-bind="aria.getItemProps(i)"
@click="performInsertion(item)"
>
{{ item.label }}
</li>
</ul>
</div>
</template> <script>
import { useMentions } from "@skyastrall/mentions-svelte";
const users = [
{ id: "1", label: "Alice" },
{ id: "2", label: "Bob" },
];
const api = useMentions({
triggers: [{ char: "@", data: users }],
});
</script>
<div data-mentions="" style="position: relative">
<div
bind:this={api.editorRef}
contenteditable="plaintext-only"
data-mentions-editor=""
role={api.aria.inputProps.role}
aria-expanded={api.aria.inputProps["aria-expanded"]}
aria-autocomplete={api.aria.inputProps["aria-autocomplete"]}
aria-haspopup={api.aria.inputProps["aria-haspopup"]}
oninput={() => api.handleInput()}
onkeydown={api.handleKeyDown}
onblur={api.handleBlur}
oncompositionstart={api.handleCompositionStart}
oncompositionend={api.handleCompositionEnd}
></div>
{#if api.isOpen}
<ul
id={api.aria.listProps.id}
role={api.aria.listProps.role}
aria-label={api.aria.listProps["aria-label"]}
style:position="fixed"
style:top="{(api.caretPosition?.top ?? 0) + (api.caretPosition?.height ?? 0) + 4}px"
style:left="{(api.caretPosition?.left ?? 0)}px"
>
{#each api.items as item, i (item.id)}
<li
id={api.aria.getItemProps(i).id}
role="option"
aria-selected={api.aria.getItemProps(i)["aria-selected"]}
onclick={() => api.performInsertion(item)}
>
{item.label}
</li>
{/each}
</ul>
{/if}
</div> import { Component, DestroyRef, inject } from "@angular/core";
import { MentionsControllerBridge } from "@skyastrall/mentions-angular";
@Component({ /* your own template with a contenteditable div */ })
export class CustomEditor {
triggers = [{ char: "@", data: users }];
bridge = new MentionsControllerBridge({ triggers: this.triggers }, inject(DestroyRef));
// bridge.state() is a signal:
// .status / .items / .query / .highlightedIndex / .caretPosition
// Read the DOM, then feed the controller:
onInput(markup: string, plainText: string, cursor: number) {
this.bridge.controller.handleInputChange(markup, plainText, cursor);
}
onKeyDown(e: KeyboardEvent) {
if (this.bridge.controller.handleKeyDown(e.key).handled) e.preventDefault();
}
} Important Attributes
data-mentions=""on a wrapper div — needed for blur detectiondata-mentions-editor=""on the editor — needed for placeholder CSSinputProps/aria.inputPropsincludesrole="combobox"and all ARIA attributes — always spread it
Reading Values
const { markup, plainText, mentions } = useMentions({ triggers });
// markup: "@[Alice](1) hello"
// plainText: "@Alice hello"
// mentions: [{ id: "1", label: "Alice" }] const { markup, plainText, mentions } = useMentions({ triggers });
// markup.value: "@[Alice](1) hello"
// plainText.value: "@Alice hello"
// mentions.value: [{ id: "1", label: "Alice" }] const api = useMentions({ triggers });
// api.markup: "@[Alice](1) hello"
// api.plainText: "@Alice hello"
// api.mentions: [{ id: "1", label: "Alice" }] // From the bridge's state signal:
bridge.state().markup; // "@[Alice](1) hello"
bridge.state().plainText; // "@Alice hello"
// Or from a <sa-mentions> template ref:
ref.getValue(); // { markup, plainText } Programmatic Actions
const { focus, clear, insertTrigger } = useMentions({ triggers });
focus(); // Focus the editor
clear(); // Clear all content
insertTrigger("@"); // Insert @ and open dropdown const { focus, clear, insertTrigger } = useMentions({ triggers });
focus(); // Focus the editor
clear(); // Clear all content
insertTrigger("@"); // Insert @ and open dropdown const api = useMentions({ triggers });
api.focus(); // Focus the editor
api.clear(); // Clear all content
api.insertTrigger("@"); // Insert @ and open dropdown // Call methods on the <sa-mentions> template ref:
ref.focus(); // Focus the editor
ref.clear(); // Clear all content
ref.insertTrigger("@"); // Insert @ and open dropdown
ref.insertText("👍"); // Insert arbitrary text at the caret