Mentions
mentionsuiTextarea with trigger-character autocomplete and companion MentionTag with Twitter/X-style hover profile card popup.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/mentions.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/mentions.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/mentions.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/mentions.jsonManifest:
https://uipkge.dev/r/svelte/mentions.jsonInstalls to:src/lib/components/ui/mentions/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
value | string | — | optional |
onValueChange | (value: string) => void | — | optional |
options | O[] | Record<string, O[]> | — | optional |
triggers | string[] | — | optional |
triggerPrefixes | Record<string, string> | — | optional |
prefix | string | — | optional |
loading | boolean | — | optional |
loadOptions | (query: string, trigger: string) => Promise<O[]> | — | optional |
format | (option: O, trigger: string) => string | — | optional |
onselect | (option: O) => void | — | optional |
onsearch | (payload: { trigger: string; query: string }) => void | — | optional |
header | Snippet<[{ trigger: string; query: string }]> | — | optional |
loadingSnippet | Snippet | — | optional |
empty | Snippet<[{ trigger: string; query: string }]> | — | optional |
option | Snippet<[{ option: O; index: number; active: boolean; trigger: string }]> | — | optional |
footer | Snippet<[{ trigger: string; count: number }]> | — | optional |
ref | HTMLTextAreaElement | null | — | 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
Files installed (4)
src/lib/components/ui/mentions/Mentions.svelte11.6 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLTextareaAttributes } from 'svelte/elements' import type { MentionOption } from './index' // `onselect` is omitted: the native select-event handler collides with the // (option) select callback. export interface MentionsProps<O extends MentionOption = MentionOption> extends Omit<HTMLTextareaAttributes, 'value' | 'prefix' | 'onselect'> { value?: string onValueChange?: (value: string) => void options?: O[] | Record<string, O[]> triggers?: string[] triggerPrefixes?: Record<string, string> prefix?: string loading?: boolean loadOptions?: (query: string, trigger: string) => Promise<O[]> format?: (option: O, trigger: string) => string onselect?: (option: O) => void onsearch?: (payload: { trigger: string; query: string }) => void header?: Snippet<[{ trigger: string; query: string }]> loadingSnippet?: Snippet empty?: Snippet<[{ trigger: string; query: string }]> option?: Snippet<[{ option: O; index: number; active: boolean; trigger: string }]> footer?: Snippet<[{ trigger: string; count: number }]> ref?: HTMLTextAreaElement | null } let mentionIdCounter = 0 </script> <script lang="ts" generics="O extends MentionOption"> import { tick } from 'svelte' import { cn } from '$lib/utils' import { getCaretRect } from './caret-position' let { class: className, value = $bindable(''), onValueChange, options = [], triggers = ['@'], triggerPrefixes, prefix = '@', rows = 4, loading = false, loadOptions, format, placeholder = '', disabled = false, readonly = false, onselect, onsearch, header, loadingSnippet, empty, option, footer, oninput, onkeydown, onscroll, ref = $bindable(null), ...restProps }: MentionsProps<O> = $props() const listboxId = `mentions-listbox-${++mentionIdCounter}` function optionId(i: number) { return `${listboxId}-opt-${i}` } let textarea: HTMLTextAreaElement | null = $state(null) let open = $state(false) let activeTrigger = $state('') let query = $state('') let triggerIndex = $state(-1) let highlightedIndex = $state(0) let asyncResults = $state<O[]>([]) let isAsyncLoading = $state(false) // Caret-anchored dropdown offset relative to the wrapper. let anchorTop = $state(0) let anchorLeft = $state(0) $effect(() => { ref = textarea }) const currentOptionsList = $derived.by((): O[] => { if (!options) return [] if (Array.isArray(options)) return options as O[] if (typeof options === 'object') { const list = (options as Record<string, O[]>)[activeTrigger] return list ?? [] } return [] }) const filtered = $derived.by((): O[] => { if (loadOptions) return asyncResults as O[] const source = currentOptionsList if (!query) return source const q = query.toLowerCase() return source.filter( (o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q) || (o.email && o.email.toLowerCase().includes(q)), ) }) const totalLoading = $derived(loading || isAsyncLoading) function findActiveMention(text: string, caret: number): { trigger: string; index: number; query: string } | null { for (let i = caret - 1; i >= 0; i--) { const ch = text[i]! if (triggers.includes(ch)) { const before = i === 0 ? '' : text[i - 1]! if (i === 0 || /\s/.test(before)) { return { trigger: ch, index: i, query: text.substring(i + 1, caret) } } return null } if (/\s/.test(ch)) return null } return null } function updateAnchor() { if (!textarea) return const rect = getCaretRect(textarea, textarea.selectionStart ?? 0) const wrap = textarea.getBoundingClientRect() // Position the panel just under the caret line, clamped inside the wrapper. anchorTop = Math.min(rect.top - wrap.top + rect.height + 4, Math.max(0, wrap.height - 8)) anchorLeft = Math.max(0, Math.min(rect.left - wrap.left, Math.max(0, wrap.width - 256))) } let asyncToken = 0 async function runAsync(trigger: string, q: string) { if (!loadOptions) return const token = ++asyncToken isAsyncLoading = true try { const results = await loadOptions(q, trigger) if (token === asyncToken) asyncResults = results } finally { if (token === asyncToken) isAsyncLoading = 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 firstEnabledIndex() { const i = filtered.findIndex((o) => !o.disabled) return i === -1 ? 0 : i } function handleInput(e: Event) { const ta = e.target as HTMLTextAreaElement value = ta.value onValueChange?.(value) const match = findActiveMention(ta.value, ta.selectionStart ?? 0) if (match) { open = true activeTrigger = match.trigger triggerIndex = match.index query = match.query highlightedIndex = firstEnabledIndex() onsearch?.({ trigger: match.trigger, query: match.query }) if (loadOptions) scheduleAsync(match.trigger, match.query) tick().then(updateAnchor) } else { open = false } } function moveHighlight(delta: number) { const len = filtered.length if (len === 0) return let i = highlightedIndex for (let n = 0; n < len; n++) { i = (i + delta + len) % len if (!filtered[i]?.disabled) { highlightedIndex = i tick().then(() => { document.getElementById(optionId(i))?.scrollIntoView({ block: 'nearest' }) }) return } } } function handleKeydown(e: KeyboardEvent) { if (open) { // Escape must work even when there are no matches / still loading. if (e.key === 'Escape') { e.preventDefault() open = false } else if (filtered.length > 0) { 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[highlightedIndex] if (opt && !opt.disabled) insert(opt) } } } } function defaultFormat(opt: O, trigger: string) { const resolvedPrefix = triggerPrefixes?.[trigger] ?? trigger ?? prefix ?? '@' return `${resolvedPrefix}${opt.value} ` } function insert(opt: O) { const ta = textarea if (!ta) return const caret = ta.selectionStart ?? 0 const before = value.substring(0, triggerIndex) const after = value.substring(caret) const token = (format ?? defaultFormat)(opt, activeTrigger) value = before + token + after onValueChange?.(value) onselect?.(opt) open = false tick().then(() => { const pos = before.length + token.length ta.focus() ta.setSelectionRange(pos, pos) }) } // Close on outside pointer down (the hand-rolled dropdown has no portal). $effect(() => { if (!open) return function onPointerDown(e: PointerEvent) { if (textarea && !textarea.parentElement?.contains(e.target as Node)) open = false } document.addEventListener('pointerdown', onPointerDown) return () => document.removeEventListener('pointerdown', onPointerDown) }) $effect(() => { return () => { if (debounceTimer) clearTimeout(debounceTimer) } }) </script> <div class={cn('relative w-full', className)} data-uipkge="" data-slot="mentions"> <textarea bind:this={textarea} {value} {rows} {placeholder} {disabled} {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" oninput={(e) => { handleInput(e) oninput?.(e) }} onkeydown={(e) => { handleKeydown(e) onkeydown?.(e) }} onscroll={(e) => { updateAnchor() onscroll?.(e) }} {...restProps} ></textarea> {#if open} <!-- Hand-rolled replacement for Popover + PopoverAnchor + PopoverContent: no headless float library is installed in the Svelte registry yet, so the listbox is absolutely positioned at the caret inside the wrapper. --> <div data-slot="mentions-content" role="presentation" class="bg-popover text-popover-foreground border-border/80 absolute z-50 w-64 rounded-lg border p-1 shadow-md" style:top="{anchorTop}px" style:left="{anchorLeft}px" > <div id={listboxId}> {#if header} {@render header({ trigger: activeTrigger, query })} {/if} {#if totalLoading} <div class="text-muted-foreground px-2 py-3 text-sm" role="status"> {#if loadingSnippet} {@render loadingSnippet()} {:else} Loading... {/if} </div> {:else if filtered.length === 0} <div class="text-muted-foreground px-2 py-3 text-sm" role="status"> {#if empty} {@render empty({ trigger: activeTrigger, query })} {:else} No matches {/if} </div> {:else} <ul class="max-h-64 overflow-auto" role="listbox" aria-label="Mentions"> {#each filtered as opt, i (opt.value)} <li id={optionId(i)} 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' : '', ] .filter(Boolean) .join(' ')} onmouseenter={() => { if (!opt.disabled) highlightedIndex = i }} onmousedown={(e) => { e.preventDefault() if (!opt.disabled) insert(opt) }} > {#if option} {@render option({ option: opt, index: i, active: i === highlightedIndex, trigger: activeTrigger })} {:else} {#if opt.avatar} <img src={opt.avatar} alt="" class="size-6 rounded-full object-cover" /> {/if} <div class="min-w-0 flex-1"> <div class="truncate font-medium">{opt.label}</div> {#if opt.description || opt.email} <div class="text-muted-foreground truncate text-xs"> {opt.description || opt.email} </div> {/if} </div> {/if} </li> {/each} </ul> {/if} {#if footer} {@render footer({ trigger: activeTrigger, count: filtered.length })} {/if} </div> </div> {/if} </div>src/lib/components/ui/mentions/MentionTag.svelte8.2 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes, HTMLAnchorAttributes } from 'svelte/elements' // `popover` is omitted: the native popover attribute collides with the // boolean profile-card prop. export interface MentionTagProps extends Omit<HTMLAttributes<HTMLElement>, 'popover'> { 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 /** Popup card content override (React parity name). */ popupContent?: Snippet /** Alias of `popupContent` (legacy Svelte name). */ popup?: Snippet children?: Snippet ref?: HTMLElement | null } </script> <script lang="ts"> import { Calendar } from '@lucide/svelte' import { cn } from '$lib/utils' let { class: className, trigger = '@', name, handle, email, avatar, bio, joined, location: _location, following, followers, verified, href, popover = true, openDelay = 150, closeDelay = 100, popupContent, popup, children, ref = $bindable(null), ...restProps }: MentionTagProps = $props() const resolvedPopup = $derived(popupContent ?? popup) // `_location` is accepted for API parity with the Vue twin; the current card // layout only renders `joined` (same as Vue). let isFollowing = $state(false) let cardOpen = $state(false) let openTimer: ReturnType<typeof setTimeout> | null = null let closeTimer: ReturnType<typeof setTimeout> | null = null function clearTimers() { if (openTimer) clearTimeout(openTimer) if (closeTimer) clearTimeout(closeTimer) openTimer = null closeTimer = null } function scheduleOpen() { if (!popover) return if (closeTimer) clearTimeout(closeTimer) closeTimer = null if (cardOpen || openTimer) return openTimer = setTimeout(() => { openTimer = null cardOpen = true }, openDelay) } function scheduleClose() { if (openTimer) clearTimeout(openTimer) openTimer = null if (!cardOpen || closeTimer) return closeTimer = setTimeout(() => { closeTimer = null cardOpen = false }, closeDelay) } $effect(() => { return () => clearTimers() }) const formattedHandle = $derived.by(() => { if (!handle && !name) return '' const h = handle || name || '' return h.startsWith(trigger) ? h : `${trigger}${h}` }) const initials = $derived.by(() => { const source = name || handle || 'U' return source.slice(0, 2).toUpperCase() }) const tagClass = $derived( 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', className, ), ) const plainTagClass = $derived( 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', className, ), ) </script> {#snippet tagContent()} {#if children} {@render children()} {:else} <span class="text-primary font-semibold">{trigger}</span> <span>{name || handle || email}</span> {/if} {/snippet} {#snippet profileCard()} {#if resolvedPopup} {@render resolvedPopup()} {:else} <!-- Twitter/X style profile popup --> <div class="space-y-3"> <div class="flex items-start justify-between gap-3"> <span class="ring-border/50 relative flex size-12 shrink-0 overflow-hidden rounded-full ring-2"> {#if avatar} <img src={avatar} alt={name || handle} class="aspect-square size-full object-cover" /> {:else} <span class="bg-muted flex size-full items-center justify-center text-sm font-semibold"> {initials} </span> {/if} </span> <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', )} onclick={(e) => { e.stopPropagation() 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> {#if verified} <span 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> {/if} </div> <p class="text-muted-foreground font-mono text-xs">{formattedHandle}</p> {#if email} <p class="text-muted-foreground mt-0.5 text-xs">{email}</p> {/if} </div> {#if bio} <p class="text-foreground/90 text-xs leading-relaxed">{bio}</p> {/if} {#if joined} <div class="text-muted-foreground flex items-center gap-4 text-xs"> <div class="flex items-center gap-1"> <Calendar class="size-3.5 opacity-70" /> <span>Joined {joined}</span> </div> </div> {/if} {#if following !== undefined || followers !== undefined} <div class="border-border/50 flex items-center gap-4 border-t pt-1 text-xs"> {#if following !== undefined} <div> <span class="text-foreground font-bold">{following}</span> <span class="text-muted-foreground ml-1">Following</span> </div> {/if} {#if followers !== undefined} <div> <span class="text-foreground font-bold">{followers}</span> <span class="text-muted-foreground ml-1">Followers</span> </div> {/if} </div> {/if} </div> {/if} {/snippet} {#if popover} <!-- Hand-rolled HoverCard: no headless float dependency in the Svelte registry yet, so open/close delays + outside dismiss are local. --> <!-- svelte-ignore a11y_no_static_element_interactions: hover bridge for the tag + card, not interactive itself --> <span class="relative inline-block" onmouseenter={scheduleOpen} onmouseleave={scheduleClose} onfocusin={scheduleOpen} onfocusout={scheduleClose} > {#if href} <a bind:this={ref} {href} data-uipkge="" data-slot="mention-tag" class={tagClass} {...(restProps as HTMLAnchorAttributes)} > {@render tagContent()} </a> {:else} <span bind:this={ref} role="button" data-uipkge="" data-slot="mention-tag" class={tagClass} tabindex="0" {...restProps} > {@render tagContent()} </span> {/if} {#if cardOpen} <!-- svelte-ignore a11y_no_static_element_interactions: hover bridge keeps the card open while hovered --> <div data-slot="mention-tag-content" class="bg-popover text-popover-foreground border-border/80 absolute top-full left-0 z-50 mt-1.5 w-80 rounded-xl border p-4 shadow-lg" onmouseenter={scheduleOpen} onmouseleave={scheduleClose} > {@render profileCard()} </div> {/if} </span> {:else} <!-- Non-popover fallback --> {#if href} <a bind:this={ref} {href} data-uipkge="" data-slot="mention-tag" class={plainTagClass} {...(restProps as HTMLAnchorAttributes)} > {@render tagContent()} </a> {:else} <span bind:this={ref} data-uipkge="" data-slot="mention-tag" class={plainTagClass} {...restProps}> {@render tagContent()} </span> {/if} {/if}src/lib/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 }src/lib/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, type MentionsProps } from './Mentions.svelte' export { default as MentionTag, type MentionTagProps } from './MentionTag.svelte'
Raw manifest:https://uipkge.dev/r/svelte/mentions.json