Mentions
mentionsuiTextarea with trigger-character autocomplete and companion MentionTag with Twitter/X-style hover profile card popup.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/mentions.json$npx shadcn@latest add https://uipkge.dev/r/react/mentions.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/mentions.json$bunx shadcn@latest add https://uipkge.dev/r/react/mentions.jsonNamed registry:
npx shadcn@latest add @uipkge-react/mentionsInstalls to:components/ui/mentions/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
valueControlled textarea value. | string | — | optional |
onValueChangeFires with the next textarea value on every input + on insert. | (value: string) => void | — | 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) => Promise<O[]> | — | optional |
format | (option: O, trigger: string) => string | — | optional |
placeholder | string | — | optional |
disabled | boolean | — | optional |
readOnly | boolean | — | optional |
className | string | — | optional |
onSelectFires when an option is committed into the text. | (option: O) => void | — | optional |
onSearchFires whenever the active mention query changes. | (payload: { trigger: string; query: string }) => void | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
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
}CaretRectinterface CaretRect {
top: number
left: number
height: number
}npm dependencies
Includes
Files installed (4)
components/ui/mentions/mentions.tsx11.3 kB
'use client' import * as React from 'react' import { cn } from '@/lib/utils' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { getCaretRect, type CaretRect } from './caret-position' 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 interface MentionsProps<O extends MentionOption = MentionOption> { /** Controlled textarea value. */ value?: string /** Fires with the next textarea value on every input + on insert. */ onValueChange?: (value: string) => void 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 className?: string /** Fires when an option is committed into the text. */ onSelect?: (option: O) => void /** Fires whenever the active mention query changes. */ onSearch?: (payload: { trigger: string; query: string }) => void } function MentionsInner<O extends MentionOption = MentionOption>( { value = '', onValueChange, options = [] as unknown as O[], triggers = ['@'], triggerPrefixes, prefix = '@', rows = 4, loading = false, loadOptions, format, placeholder = '', disabled = false, readOnly = false, className, onSelect, onSearch, }: MentionsProps<O>, ref: React.ForwardedRef<HTMLTextAreaElement>, ) { const innerRef = React.useRef<HTMLTextAreaElement | null>(null) const setRefs = React.useCallback( (node: HTMLTextAreaElement | null) => { innerRef.current = node if (typeof ref === 'function') ref(node) else if (ref) (ref as React.MutableRefObject<HTMLTextAreaElement | null>).current = node }, [ref], ) const [open, setOpen] = React.useState(false) const [activeTrigger, setActiveTrigger] = React.useState('') const [query, setQuery] = React.useState('') const [triggerIndex, setTriggerIndex] = React.useState(-1) const [highlightedIndex, setHighlightedIndex] = React.useState(0) const [asyncResults, setAsyncResults] = React.useState<O[]>([]) const [isAsyncLoading, setIsAsyncLoading] = React.useState(false) const [caretRect, setCaretRect] = React.useState<CaretRect | null>(null) const listboxId = React.useId() const optionId = (i: number) => `${listboxId}-opt-${i}` const currentOptionsList = React.useMemo<O[]>(() => { if (!options) return [] if (Array.isArray(options)) return options if (typeof options === 'object') { return (options as Record<string, O[]>)[activeTrigger] ?? [] } return [] }, [options, activeTrigger]) const filtered = React.useMemo<O[]>(() => { if (loadOptions) return asyncResults 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)), ) }, [loadOptions, asyncResults, query, currentOptionsList]) const totalLoading = loading || isAsyncLoading function firstEnabledIndex(list: O[] = filtered) { const i = list.findIndex((o) => !o.disabled) return i === -1 ? 0 : i } function moveHighlight(delta: number) { const len = filtered.length if (len === 0) return setHighlightedIndex((current) => { let i = current for (let n = 0; n < len; n++) { i = (i + delta + len) % len if (!filtered[i]?.disabled) { const opt = document.getElementById(optionId(i)) opt?.scrollIntoView({ block: 'nearest' }) return i } } return current }) } function findActiveMention(val: string, caret: number): { trigger: string; index: number; query: string } | null { for (let i = caret - 1; i >= 0; i--) { const ch = val[i]! if (triggers.includes(ch)) { const before = i === 0 ? '' : val[i - 1]! if (i === 0 || /\s/.test(before)) { return { trigger: ch, index: i, query: val.substring(i + 1, caret) } } return null } if (/\s/.test(ch)) return null } return null } const updateAnchor = React.useCallback(() => { const ta = innerRef.current if (!ta) return setCaretRect(getCaretRect(ta, ta.selectionStart ?? 0)) }, []) const asyncTokenRef = React.useRef(0) const runAsync = React.useCallback( async (trigger: string, q: string) => { if (!loadOptions) return const token = ++asyncTokenRef.current setIsAsyncLoading(true) try { const results = await loadOptions(q, trigger) if (token === asyncTokenRef.current) setAsyncResults(results) } finally { if (token === asyncTokenRef.current) setIsAsyncLoading(false) } }, [loadOptions], ) const debounceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null) const scheduleAsync = React.useCallback( (trigger: string, q: string) => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) debounceTimerRef.current = setTimeout(() => runAsync(trigger, q), 200) }, [runAsync], ) React.useEffect(() => { return () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) } }, []) function onInput(e: React.ChangeEvent<HTMLTextAreaElement>) { const next = e.target.value onValueChange?.(next) const match = findActiveMention(next, e.target.selectionStart ?? 0) if (match) { setOpen(true) setActiveTrigger(match.trigger) setTriggerIndex(match.index) setQuery(match.query) onSearch?.({ trigger: match.trigger, query: match.query }) if (loadOptions) scheduleAsync(match.trigger, match.query) requestAnimationFrame(updateAnchor) } else { setOpen(false) } } function defaultFormat(option: O, trigger: string) { const resolvedPrefix = triggerPrefixes?.[trigger] ?? trigger ?? prefix ?? '@' return `${resolvedPrefix}${option.value} ` } function insert(option: O) { const ta = innerRef.current if (!ta) return const caret = ta.selectionStart ?? 0 const before = value.substring(0, triggerIndex) const after = value.substring(caret) const token = (format ?? defaultFormat)(option, activeTrigger) const next = before + token + after onValueChange?.(next) onSelect?.(option) setOpen(false) requestAnimationFrame(() => { const pos = before.length + token.length ta.focus() ta.setSelectionRange(pos, pos) }) } function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) { if (!open) return if (e.key === 'Escape') { e.preventDefault() setOpen(false) return } if (filtered.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[highlightedIndex] if (opt && !opt.disabled) insert(opt) } } const anchorStyle: React.CSSProperties = React.useMemo(() => { if (!caretRect) return { display: 'none' } return { position: 'fixed', top: `${caretRect.top + caretRect.height}px`, left: `${caretRect.left}px`, width: '0px', height: '0px', pointerEvents: 'none', } }, [caretRect]) return ( <div className={cn('relative w-full', className)} data-uipkge data-slot="mentions"> <textarea ref={setRefs} value={value} 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} className="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" onChange={onInput} onKeyDown={onKeyDown} onScroll={updateAnchor} /> <Popover open={open} onOpenChange={setOpen}> <PopoverAnchor asChild> <div style={anchorStyle} aria-hidden="true" /> </PopoverAnchor> <PopoverContent align="start" sideOffset={4} className="border-border/80 w-64 rounded-lg p-1 shadow-md" onOpenAutoFocus={(e) => e.preventDefault()} > <div id={listboxId}> {totalLoading ? ( <div className="text-muted-foreground px-2 py-3 text-sm" role="status"> Loading... </div> ) : filtered.length === 0 ? ( <div className="text-muted-foreground px-2 py-3 text-sm" role="status"> No matches </div> ) : ( <ul className="max-h-64 overflow-auto" role="listbox" aria-label="Mentions"> {filtered.map((opt, i) => { const active = i === highlightedIndex return ( <li key={opt.value} id={optionId(i)} role="option" aria-selected={active} aria-disabled={opt.disabled || undefined} className={cn( 'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors', active && !opt.disabled ? 'bg-accent text-accent-foreground' : '', opt.disabled ? 'cursor-not-allowed opacity-50' : '', )} onMouseEnter={() => !opt.disabled && setHighlightedIndex(i)} onMouseDown={(e) => { e.preventDefault() if (!opt.disabled) insert(opt) }} > {opt.avatar && <img src={opt.avatar} alt="" className="size-6 rounded-full object-cover" />} <div className="min-w-0 flex-1"> <div className="truncate font-medium">{opt.label}</div> {(opt.description || opt.email) && ( <div className="text-muted-foreground truncate text-xs">{opt.description || opt.email}</div> )} </div> </li> ) })} </ul> )} </div> </PopoverContent> </Popover> </div> ) } export const Mentions = React.forwardRef(MentionsInner) as <O extends MentionOption = MentionOption>( props: MentionsProps<O> & { ref?: React.ForwardedRef<HTMLTextAreaElement> }, ) => React.ReactElement export default Mentionscomponents/ui/mentions/mention-tag.tsx5.5 kB
'use client' import * as React from 'react' import { Calendar } from 'lucide-react' import { cn } from '@/lib/utils' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' export interface MentionTagProps extends Omit<React.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 popupContent?: React.ReactNode } export function MentionTag({ trigger = '@', name, handle, email, avatar, bio, joined, location, following, followers, verified, href, popover = true, openDelay = 150, closeDelay = 100, popupContent, className, children, ...props }: MentionTagProps) { const [isFollowing, setIsFollowing] = React.useState(false) const formattedHandle = React.useMemo(() => { if (!handle && !name) return '' const h = handle || name || '' return h.startsWith(trigger) ? h : `${trigger}${h}` }, [handle, name, trigger]) const initials = React.useMemo(() => { const source = name || handle || 'U' return source.slice(0, 2).toUpperCase() }, [name, handle]) const TagElement = href ? 'a' : 'span' const triggerEl = ( <TagElement href={href} data-uipkge data-slot="mention-tag" className={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, )} {...(props as any)} > {children || ( <> <span className="text-primary font-semibold">{trigger}</span> <span>{name || handle || email}</span> </> )} </TagElement> ) if (!popover) return triggerEl return ( <HoverCard openDelay={openDelay} closeDelay={closeDelay}> <HoverCardTrigger asChild>{triggerEl}</HoverCardTrigger> <HoverCardContent align="start" sideOffset={6} className="border-border/80 w-80 rounded-xl p-4 shadow-lg"> {popupContent || ( <div className="space-y-3"> <div className="flex items-start justify-between gap-3"> <Avatar className="ring-border/50 size-12 ring-2"> {avatar && <AvatarImage src={avatar} alt={name || handle || ''} />} <AvatarFallback className="text-sm font-semibold">{initials}</AvatarFallback> </Avatar> <button type="button" className={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() setIsFollowing((prev) => !prev) }} > {isFollowing ? 'Following' : 'Follow'} </button> </div> <div> <div className="flex items-center gap-1"> <span className="text-foreground text-sm font-bold tracking-tight">{name || handle}</span> {verified && ( <span className="text-primary inline-flex" title="Verified"> <svg className="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 className="text-muted-foreground font-mono text-xs">{formattedHandle}</p> {email && <p className="text-muted-foreground mt-0.5 text-xs">{email}</p>} </div> {bio && <p className="text-foreground/90 text-xs leading-relaxed">{bio}</p>} {(joined || location) && ( <div className="text-muted-foreground flex items-center gap-4 text-xs"> {joined && ( <div className="flex items-center gap-1"> <Calendar className="size-3.5 opacity-70" /> <span>Joined {joined}</span> </div> )} </div> )} {(following !== undefined || followers !== undefined) && ( <div className="border-border/50 flex items-center gap-4 border-t pt-1 text-xs"> {following !== undefined && ( <div> <span className="text-foreground font-bold">{following}</span> <span className="text-muted-foreground ml-1">Following</span> </div> )} {followers !== undefined && ( <div> <span className="text-foreground font-bold">{followers}</span> <span className="text-muted-foreground ml-1">Followers</span> </div> )} </div> )} </div> )} </HoverCardContent> </HoverCard> ) }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 }components/ui/mentions/index.ts0.1 kB
export { Mentions, type MentionsProps, type MentionOption } from './mentions' export { MentionTag, type MentionTagProps } from './mention-tag'
Raw manifest:https://uipkge.dev/r/react/mentions.json