Mentions
mentionsuiTextarea with trigger-character autocomplete and companion MentionTag with Twitter/X-style hover profile card popup.
Installation
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/mentions.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/mentions.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/mentions.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/mentions.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/mentionsInstalls to:app/components/ui/mentions/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
modelValue | string | — | optional |
options | O[] | Record<string, O[]> | — | optional |
triggers | string[] | — | optional |
triggerPrefixes | Record<string, string> | — | optional |
prefix | string | — | optional |
rows | number | — | optional |
loading | boolean | — | optional |
loadOptions | (query: string, trigger: string) | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
CaretRectinterface CaretRect {
top: number
left: number
height: number
}MentionOptioninterface MentionOption {
value: string
label: string
description?: string
avatar?: string
email?: string
handle?: string
bio?: string
joined?: string
following?: number | string
followers?: number | string
verified?: boolean
disabled?: boolean
[key: string]: any
}npm dependencies
Includes
Files installed (4)
app/components/ui/mentions/Mentions.vue9.6 kB
<script setup lang="ts" generic="O extends MentionOption"> import { computed, nextTick, onBeforeUnmount, ref, useId } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { getCaretRect, type CaretRect } from './caret-position' import type { MentionOption } from '.' const props = withDefaults( defineProps<{ modelValue?: string options?: O[] | Record<string, O[]> triggers?: string[] triggerPrefixes?: Record<string, string> prefix?: string rows?: number loading?: boolean loadOptions?: (query: string, trigger: string) => Promise<O[]> format?: (option: O, trigger: string) => string placeholder?: string disabled?: boolean readonly?: boolean class?: HTMLAttributes['class'] }>(), { modelValue: '', options: () => [] as any, triggers: () => ['@'], prefix: '@', rows: 4, loading: false, placeholder: '', disabled: false, readonly: false, }, ) const listboxId = useId() function optionId(i: number) { return `${listboxId}-opt-${i}` } const emits = defineEmits<{ (e: 'update:modelValue', value: string): void (e: 'select', option: O): void (e: 'search', payload: { trigger: string; query: string }): void }>() const textarea = ref<HTMLTextAreaElement | null>(null) const open = ref(false) const activeTrigger = ref('') const query = ref('') const triggerIndex = ref(-1) const highlightedIndex = ref(0) const asyncResults = ref<O[]>([]) const isAsyncLoading = ref(false) const caretRect = ref<CaretRect | null>(null) const currentOptionsList = computed<O[]>(() => { if (!props.options) return [] if (Array.isArray(props.options)) return props.options as O[] if (typeof props.options === 'object') { const list = (props.options as Record<string, O[]>)[activeTrigger.value] return list ?? [] } return [] }) const filtered = computed((): O[] => { if (props.loadOptions) return asyncResults.value as O[] const source = currentOptionsList.value if (!query.value) return source const q = query.value.toLowerCase() return source.filter( (o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q) || (o.email && o.email.toLowerCase().includes(q)), ) }) const totalLoading = computed(() => props.loading || isAsyncLoading.value) function findActiveMention(value: string, caret: number): { trigger: string; index: number; query: string } | null { for (let i = caret - 1; i >= 0; i--) { const ch = value[i]! if (props.triggers.includes(ch)) { const before = i === 0 ? '' : value[i - 1]! if (i === 0 || /\s/.test(before)) { return { trigger: ch, index: i, query: value.substring(i + 1, caret) } } return null } if (/\s/.test(ch)) return null } return null } function updateAnchor() { if (!textarea.value) return caretRect.value = getCaretRect(textarea.value, textarea.value.selectionStart ?? 0) } let asyncToken = 0 async function runAsync(trigger: string, q: string) { if (!props.loadOptions) return const token = ++asyncToken isAsyncLoading.value = true try { const results = await props.loadOptions(q, trigger) if (token === asyncToken) asyncResults.value = results } finally { if (token === asyncToken) isAsyncLoading.value = false } } let debounceTimer: ReturnType<typeof setTimeout> | null = null function scheduleAsync(trigger: string, q: string) { if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = setTimeout(() => runAsync(trigger, q), 200) } function onInput(e: Event) { const ta = e.target as HTMLTextAreaElement emits('update:modelValue', ta.value) const match = findActiveMention(ta.value, ta.selectionStart ?? 0) if (match) { open.value = true activeTrigger.value = match.trigger triggerIndex.value = match.index query.value = match.query highlightedIndex.value = firstEnabledIndex() emits('search', { trigger: match.trigger, query: match.query }) if (props.loadOptions) scheduleAsync(match.trigger, match.query) nextTick(updateAnchor) } else { open.value = false } } function firstEnabledIndex() { const i = filtered.value.findIndex((o) => !o.disabled) return i === -1 ? 0 : i } function moveHighlight(delta: number) { const len = filtered.value.length if (len === 0) return let i = highlightedIndex.value for (let n = 0; n < len; n++) { i = (i + delta + len) % len if (!filtered.value[i]?.disabled) { highlightedIndex.value = i nextTick(() => { document.getElementById(optionId(i))?.scrollIntoView({ block: 'nearest' }) }) return } } } function onKeydown(e: KeyboardEvent) { if (!open.value) return // Escape must work even when there are no matches / still loading. if (e.key === 'Escape') { e.preventDefault() open.value = false return } if (filtered.value.length === 0) return if (e.key === 'ArrowDown') { e.preventDefault() moveHighlight(1) } else if (e.key === 'ArrowUp') { e.preventDefault() moveHighlight(-1) } else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault() const opt = filtered.value[highlightedIndex.value] if (opt && !opt.disabled) insert(opt) } } function defaultFormat(option: O, trigger: string) { const resolvedPrefix = props.triggerPrefixes?.[trigger] ?? trigger ?? props.prefix ?? '@' return `${resolvedPrefix}${option.value} ` } function insert(option: O) { const ta = textarea.value if (!ta) return const value = props.modelValue const caret = ta.selectionStart ?? 0 const before = value.substring(0, triggerIndex.value) const after = value.substring(caret) const token = (props.format ?? defaultFormat)(option, activeTrigger.value) const next = before + token + after emits('update:modelValue', next) emits('select', option) open.value = false nextTick(() => { const pos = before.length + token.length ta.focus() ta.setSelectionRange(pos, pos) }) } const anchorStyle = computed(() => { if (!caretRect.value) return { display: 'none' } return { position: 'fixed' as const, top: `${caretRect.value.top + caretRect.value.height}px`, left: `${caretRect.value.left}px`, width: '0px', height: '0px', pointerEvents: 'none' as const, } }) onBeforeUnmount(() => { if (debounceTimer) clearTimeout(debounceTimer) }) </script> <template> <div :class="cn('relative w-full', props.class)" data-uipkge data-slot="mentions"> <textarea ref="textarea" :value="modelValue" :rows="rows" :placeholder="placeholder" :disabled="disabled" :readonly="readonly" role="combobox" aria-autocomplete="list" aria-haspopup="listbox" :aria-expanded="open" :aria-controls="listboxId" :aria-activedescendant="open && filtered.length > 0 ? optionId(highlightedIndex) : undefined" class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-16 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" @input="onInput" @keydown="onKeydown" @scroll="updateAnchor" /> <Popover :open="open" @update:open="open = $event"> <PopoverAnchor as-child> <div :style="anchorStyle" aria-hidden="true" /> </PopoverAnchor> <PopoverContent align="start" :side-offset="4" class="border-border/80 w-64 rounded-lg p-1 shadow-md" @open-auto-focus="(e: Event) => e.preventDefault()" > <div :id="listboxId"> <slot name="header" :trigger="activeTrigger" :query="query" /> <div v-if="totalLoading" class="text-muted-foreground px-2 py-3 text-sm" role="status"> <slot name="loading">Loading...</slot> </div> <div v-else-if="filtered.length === 0" class="text-muted-foreground px-2 py-3 text-sm" role="status"> <slot name="empty" :trigger="activeTrigger" :query="query">No matches</slot> </div> <ul v-else class="max-h-64 overflow-auto" role="listbox" aria-label="Mentions"> <li v-for="(opt, i) in filtered" :id="optionId(i)" :key="opt.value" role="option" :aria-selected="i === highlightedIndex" :aria-disabled="opt.disabled || undefined" :class="[ 'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors', i === highlightedIndex && !opt.disabled ? 'bg-accent text-accent-foreground' : '', opt.disabled ? 'cursor-not-allowed opacity-50' : '', ]" @mouseenter="!opt.disabled && (highlightedIndex = i)" @mousedown.prevent="!opt.disabled && insert(opt)" > <slot name="option" :option="opt" :index="i" :active="i === highlightedIndex" :trigger="activeTrigger"> <img v-if="opt.avatar" :src="opt.avatar" alt="" class="size-6 rounded-full object-cover" /> <div class="min-w-0 flex-1"> <div class="truncate font-medium">{{ opt.label }}</div> <div v-if="opt.description || opt.email" class="text-muted-foreground truncate text-xs"> {{ opt.description || opt.email }} </div> </div> </slot> </li> </ul> <slot name="footer" :trigger="activeTrigger" :count="filtered.length" /> </div> </PopoverContent> </Popover> </div> </template>app/components/ui/mentions/MentionTag.vue5.4 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { computed, ref } from 'vue' import { Calendar, Check, ExternalLink } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' interface Props { trigger?: string name?: string handle?: string email?: string avatar?: string bio?: string joined?: string location?: string following?: number | string followers?: number | string verified?: boolean href?: string popover?: boolean openDelay?: number closeDelay?: number class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { trigger: '@', popover: true, openDelay: 150, closeDelay: 100, }) const isFollowing = ref(false) const formattedHandle = computed(() => { if (!props.handle && !props.name) return '' const h = props.handle || props.name || '' return h.startsWith(props.trigger) ? h : `${props.trigger}${h}` }) const initials = computed(() => { const source = props.name || props.handle || 'U' return source.slice(0, 2).toUpperCase() }) </script> <template> <HoverCard v-if="popover" :open-delay="openDelay" :close-delay="closeDelay"> <HoverCardTrigger as-child> <component :is="href ? 'a' : 'span'" :href="href" data-uipkge data-slot="mention-tag" :class=" cn( 'inline-flex cursor-pointer items-center gap-0.5 rounded px-1.5 py-0.5 text-sm font-medium transition-colors select-none', 'bg-muted/70 text-foreground hover:bg-accent hover:text-accent-foreground', 'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none', props.class, ) " > <slot> <span class="text-primary font-semibold">{{ trigger }}</span> <span>{{ name || handle || email }}</span> </slot> </component> </HoverCardTrigger> <HoverCardContent align="start" :side-offset="6" class="border-border/80 w-80 rounded-xl p-4 shadow-lg"> <slot name="popup"> <!-- Twitter/X style profile popup --> <div class="space-y-3"> <div class="flex items-start justify-between gap-3"> <Avatar class="ring-border/50 size-12 ring-2"> <AvatarImage v-if="avatar" :src="avatar" :alt="name || handle" /> <AvatarFallback class="text-sm font-semibold">{{ initials }}</AvatarFallback> </Avatar> <button type="button" :class=" cn( 'h-8 rounded-full px-3.5 text-xs font-semibold transition-[transform,background-color] duration-150 active:scale-95', isFollowing ? 'border-border text-foreground hover:bg-muted border bg-transparent' : 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs', ) " @click.stop="isFollowing = !isFollowing" > {{ isFollowing ? 'Following' : 'Follow' }} </button> </div> <div> <div class="flex items-center gap-1"> <span class="text-foreground text-sm font-bold tracking-tight">{{ name || handle }}</span> <span v-if="verified" class="text-primary inline-flex" title="Verified"> <svg class="size-3.5 fill-current" viewBox="0 0 24 24" aria-hidden="true"> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" /> </svg> </span> </div> <p class="text-muted-foreground font-mono text-xs">{{ formattedHandle }}</p> <p v-if="email" class="text-muted-foreground mt-0.5 text-xs">{{ email }}</p> </div> <p v-if="bio" class="text-foreground/90 text-xs leading-relaxed">{{ bio }}</p> <div v-if="joined || location" class="text-muted-foreground flex items-center gap-4 text-xs"> <div v-if="joined" class="flex items-center gap-1"> <Calendar class="size-3.5 opacity-70" /> <span>Joined {{ joined }}</span> </div> </div> <div v-if="following !== undefined || followers !== undefined" class="border-border/50 flex items-center gap-4 border-t pt-1 text-xs" > <div v-if="following !== undefined"> <span class="text-foreground font-bold">{{ following }}</span> <span class="text-muted-foreground ml-1">Following</span> </div> <div v-if="followers !== undefined"> <span class="text-foreground font-bold">{{ followers }}</span> <span class="text-muted-foreground ml-1">Followers</span> </div> </div> </div> </slot> </HoverCardContent> </HoverCard> <!-- Non-popover fallback --> <component :is="href ? 'a' : 'span'" v-else :href="href" data-uipkge data-slot="mention-tag" :class=" cn( 'inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-sm font-medium transition-colors', 'bg-muted/70 text-foreground', props.class, ) " > <slot> <span class="text-primary font-semibold">{{ trigger }}</span> <span>{{ name || handle || email }}</span> </slot> </component> </template>app/components/ui/mentions/caret-position.ts1.8 kB
const PROPERTIES_TO_COPY = [ 'direction', 'boxSizing', 'width', 'height', 'overflowX', 'overflowY', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderStyle', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', 'fontSizeAdjust', 'lineHeight', 'fontFamily', 'textAlign', 'textTransform', 'textIndent', 'textDecoration', 'letterSpacing', 'wordSpacing', 'tabSize', 'whiteSpace', 'wordBreak', 'wordWrap', ] as const export interface CaretRect { top: number left: number height: number } export function getCaretRect(textarea: HTMLTextAreaElement, position: number): CaretRect { const div = document.createElement('div') document.body.appendChild(div) const style = div.style const computed = window.getComputedStyle(textarea) style.position = 'absolute' style.visibility = 'hidden' style.whiteSpace = 'pre-wrap' style.wordWrap = 'break-word' style.top = '0' style.left = '0' for (const prop of PROPERTIES_TO_COPY) { ;(style as any)[prop] = (computed as any)[prop] } style.overflow = 'hidden' const text = textarea.value.substring(0, position) div.textContent = text const span = document.createElement('span') span.textContent = textarea.value.substring(position) || '.' div.appendChild(span) const spanRect = span.getBoundingClientRect() const divRect = div.getBoundingClientRect() const taRect = textarea.getBoundingClientRect() const result: CaretRect = { top: taRect.top + (spanRect.top - divRect.top) - textarea.scrollTop, left: taRect.left + (spanRect.left - divRect.left) - textarea.scrollLeft, height: spanRect.height, } document.body.removeChild(div) return result }app/components/ui/mentions/index.ts0.4 kB
export interface MentionOption { value: string label: string description?: string avatar?: string email?: string handle?: string bio?: string joined?: string following?: number | string followers?: number | string verified?: boolean disabled?: boolean [key: string]: any } export { default as Mentions } from './Mentions.vue' export { default as MentionTag } from './MentionTag.vue'
Raw manifest:https://uipkge.dev/r/vue/mentions.json