A tiny, headless, framework-agnostic engine for @mentions, #hashtags, /slash commands, and any custom trigger.
One ~9 KB core. React, Vue 3, and Svelte 5 adapters. Zero runtime dependencies. WAI-ARIA combobox out of the box.
| Package | For | Install | Version |
|---|---|---|---|
@skyastrall/mentions-react |
React 18 / 19 | npm i @skyastrall/mentions-react |
|
@skyastrall/mentions-vue |
Vue 3.4+ | npm i @skyastrall/mentions-vue |
|
@skyastrall/mentions-svelte |
Svelte 5 | npm i @skyastrall/mentions-svelte |
|
@skyastrall/mentions-core |
Any framework / vanilla JS | npm i @skyastrall/mentions-core |
Every adapter is a thin (~5 KB) wrapper around the same MentionController from core. Pick one, or use them side-by-side in the same monorepo.
import { Mentions } from "@skyastrall/mentions-react";
const users = [{ id: "1", label: "Alice" }, { id: "2", label: "Bob" }];
<Mentions
triggers={[{ char: "@", data: users, color: "rgba(99,102,241,0.25)" }]}
onChange={(markup, plainText) => console.log(markup)}
/>;
<script setup>
import { ref } from "vue";
import { Mentions } from "@skyastrall/mentions-vue";
const users = [{ id: "1", label: "Alice" }, { id: "2", label: "Bob" }];
const markup = ref("");
</script>
<template>
<Mentions
:triggers="[{ char: '@', data: users, color: 'rgba(99,102,241,0.25)' }]"
v-model="markup"
/>
</template>
<script>
import { Mentions } from "@skyastrall/mentions-svelte";
const users = [{ id: "1", label: "Alice" }, { id: "2", label: "Bob" }];
let markup = $state("");
</script>
<Mentions
triggers={[{ char: "@", data: users, color: "rgba(99,102,241,0.25)" }]}
onChange={(m) => (markup = m)}
/>
More patterns — multi-trigger, async data, compound components, ghost text, single-line mode, headless hook/composable/runes — live in the docs.
Every adapter ships the same three layers, so you can match the API to how much control you need.
1. Drop-in component — works without any plumbing.
<Mentions triggers={triggers} onChange={handleChange} />
2. Compound components — own the layout, keep the behavior.
<Mentions triggers={triggers}>
<Mentions.Editor placeholder="Type @..." />
<Mentions.Portal>
<Mentions.List>
<Mentions.Item render={({ item }) => <UserCard user={item} />} />
</Mentions.List>
</Mentions.Portal>
</Mentions>
3. Headless hook / composable / runes — full control. Bring your own UI.
const { editorRef, inputProps, isOpen, items, getItemProps } =
useMentions({ triggers });
The hook (useMentions in React/Vue, runes-powered in Svelte) is the same shape everywhere — same state, same handlers, same ARIA wiring. Adapters are intentionally thin.
@, #, /, or any character. Per-trigger colors and independent data sources.onError.data-gramm attributes + node filtering so third-party extensions don't hijack the editor.TriggerConfig<TData> all the way through onSelect.It's headless, not "headless-ish". Zero CSS opinions. No portal magic. You bring the UI.
It's not a full editor. If you need WYSIWYG, formatting toolbars, tables, or images — use Tiptap or Lexical. If you need clean, fast @mentions / #tags / /commands in a textarea-like surface, this is the smallest correct answer.
It's actually multi-framework. One MentionController in core, three thin adapters that all behave identically. Not a React port grudgingly ported to Vue.
No runtime deps in core. The engine runs in Node — pure logic, no jsdom required. That keeps the install graph tiny and the unit tests honest.
packages/
core/ framework-agnostic engine — MentionController, state machine, parser
react/ React 18/19 adapter
vue/ Vue 3.4+ adapter
svelte/ Svelte 5 adapter (runes)
website/ Astro docs + playground (mentions.skyastrall.com)
playground/ Vite dev sandbox
e2e/ Playwright end-to-end tests
pnpm install
pnpm build # build all packages
pnpm test -- --run # unit tests (Vitest)
pnpm test:e2e # Playwright
pnpm lint # Biome
See CONTRIBUTING.md for the full workflow.
MIT — built by SkyAstrall.