Skip to content

Mention

The mention component is extends a standard textarea with mention and trigger support (e.g., @username, #tag). It helps users type rich content with interactive mentions by providing:

  • Triggers – Define one or more characters (e.g., @, #) that activate mention suggestions.
  • Dynamic Options – Pass a list of mentionable items (users, tags, etc.) from the parent component.
  • Autocomplete Dropdown – Displays filtered suggestions as the user types after a trigger.
  • Keyboard Navigation – Navigate suggestions with arrow keys, confirm with Enter, or cancel with Escape.
  • Mentions Extraction – Emits a structured list of selected mentions so the parent can track which entities were referenced.

Examples

The height of the Mention Autosize component automatically adjusts as a response to keyboard inputs and window resizing events.

Code
vue
<script setup lang="ts">
import { computed, ref } from "vue"

type Trigger = '@' | '#'

interface MentionOption {
  id: string | number
  label: string
  link?: string
}

interface Token {
  text: string
  type: "text" | "mention"
  link?: string
}

const text = ref("")
const mentions = ref<MentionOption[]>([])

const userOptions = ref<MentionOption[]>([
  { id: 1, label: "alice" },
  { id: 2, label: "bob" },
  { id: 3, label: "charlie" },
  { id: 4, label: "dwaynejohnson" },
])
const tagOptions = ref<MentionOption[]>([
  { id: "t1", label: "vue" },
  { id: "t2", label: "typescript" }
])

const loading = ref(false)

function onSearch(payload: { trigger: Trigger; query: string }) {
  console.log("Searching", payload)
}

// Quick lookup maps (trigger -> { label: link })
const lookup = computed<Record<Trigger, Record<string, string | undefined>>>(() => ({
  "@": Object.fromEntries(userOptions.value.map(u => [u.label.toLowerCase(), u.link])),
  "#": Object.fromEntries(tagOptions.value.map(t => [t.label.toLowerCase(), t.link])),
}))

// Tokenize preview
const tokens = computed<Token[]>(() => {
  const regex = /([@#]\w+)/g
  const parts: Token[] = []
  let last = 0
  let m: RegExpExecArray | null

  while ((m = regex.exec(text.value)) !== null) {
    if (m.index > last) {
      parts.push({ text: text.value.slice(last, m.index), type: "text" })
    }

    const trig = m[0][0] as Trigger
    const lbl = m[0].slice(1).toLowerCase()

    parts.push({
      text: m[0],
      type: "mention",
      link: lookup.value[trig]?.[lbl],
    })

    last = regex.lastIndex
  }

  if (last < text.value.length) {
    parts.push({ text: text.value.slice(last), type: "text" })
  }

  return parts
})
</script>

<template>
  <form @submit.prevent="">
    <base-mention v-model="text" :options="{ '@': userOptions, '#': tagOptions }" :triggers="['@', '#']"
      :loading="loading" @update:mentions="mentions = $event" @search="onSearch" :min-height="100" border="full" />
  </form>

  <div style="white-space: pre-wrap;">
    <h1>Preview</h1>
    <div style="border: #64748b 0.5px solid; padding: 8px; min-height: 100px;">
      <template v-for="(t, i) in tokens" :key="i">
        <a v-if="t.type === 'mention' && t.link" :href="t.link" target="_blank" style="color: blue;">
          {{ t.text }}
        </a>
        <span v-else-if="t.type === 'mention'" style="color: blue;">
          {{ t.text }}
        </span>
        <span v-else>
          {{ t.text }}
        </span>
      </template>
    </div>
  </div>
</template>

Mention API

Types

ts
export type BaseMentionBorderType = 'simple' | 'full' | 'none'
export type BaseFormLayoutType = 'vertical' | 'horizontal'
export type MentionOptionsMap = Record<string, IMentionOption[]>
export type SearchType = { payload: { trigger: string; query: string } }
export interface IMentionOption {
  id: number | string
  label: string
  link?: string
}

Props

NameTypeDefaultDescription
v-modelstringv-model is required.
v-model:errorsstring[]Mention error message.
@update:mentionsIMentionOption[]Emits the list of mentions currently found in the textarea.
@searchSearchTypeFired when a trigger is typed, useful for async option loading.
optionsMentionOptionsMapAvailable mention options grouped by trigger character.
triggersstring[]List of trigger characters to activate mention suggestions.
loadingbooleanfalseShows a loading state in the suggestions dropdown (e.g., "Searching…").
idstringMention id.
labelstringMention label.
descriptionstringMention description.
placeholderstringMention placeholder.
borderBaseMentionBorderTypeMention border.
layoutBaseFormLayoutTypeMention layout.
maxlengthnumberMention Max Length.
autofocusbooleanfalseFocus mention on page load.
requiredbooleanfalseif true mention is required.
disabledbooleanfalseif true mention is disabled.
readonlybooleanfalseif true mention is readonly.
helpersstring[]Mention helper message.
minHeightnumberMention min height.
maxHeightnumberMention max height.
data-testidstringTesting ID.

Automated Test Guide

If you pass a data-testid to the <base-mention> component, it will automatically generate unique data-testid attributes for testing.

Gherkin Scenario

txt
When I type "I need to upload a document file here." into "notes"

Step Definition

ts
When('I type {string} into {string}', (value: string, selector: string) => {
  cy.get(`[data-testid="${selector}"]`).type(value)
})

Code Implementation

vue
<script setup>
import { ref } from 'vue'

const notes = ref()
</script>

<template>
  <base-mention v-model="notes" data-testid="notes" />
</template>

Released under the MIT License.