Mentions
React formTextarea with trigger-character autocomplete. Type a configured trigger (default @) to open a filtered popover; pick to insert. Static or async options.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/mentions.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/mentions.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/mentions.json$ bunx shadcn@latest add https://react.uipkge.dev/r/react/mentions.json
Or with the named registry:
npx shadcn@latest add @uipkge-react/mentions
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
value Controlled textarea value. | string | — | optional |
onValueChange Fires with the next textarea value on every input + on insert. | (value: string) => void | — | optional |
options | O[] | — | optional |
triggers | 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 |
onSelect Fires when an option is committed into the text. | (option: O) => void | — | optional |
onSearch Fires 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.
MentionOption interface MentionOption {
value: string
label: string
description?: string
avatar?: string
disabled?: boolean
} CaretRect interface CaretRect {
top: number
left: number
height: number
} Dependencies
Files (3)
-
components/ui/mentions/mentions.tsx 9.2 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 disabled?: boolean } 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[] triggers?: 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 = ['@'], 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 filtered = React.useMemo<O[]>(() => { if (loadOptions) return asyncResults if (!query) return options const q = query.toLowerCase() return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)) }, [loadOptions, asyncResults, query, options]) const totalLoading = loading || isAsyncLoading 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(() => { if (!innerRef.current) return setCaretRect(getCaretRect(innerRef.current, innerRef.current.selectionStart ?? 0)) }, []) const asyncToken = React.useRef(0) const runAsync = React.useCallback( async (trigger: string, q: string) => { if (!loadOptions) return const token = ++asyncToken.current setIsAsyncLoading(true) try { const results = await loadOptions(q, trigger) if (token === asyncToken.current) setAsyncResults(results) } finally { if (token === asyncToken.current) setIsAsyncLoading(false) } }, [loadOptions], ) const debounceTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null) const scheduleAsync = React.useCallback( (trigger: string, q: string) => { if (debounceTimer.current) clearTimeout(debounceTimer.current) debounceTimer.current = setTimeout(() => runAsync(trigger, q), 200) }, [runAsync], ) React.useEffect(() => { return () => { if (debounceTimer.current) clearTimeout(debounceTimer.current) } }, []) function onInput(e: React.ChangeEvent<HTMLTextAreaElement>) { const ta = e.target onValueChange?.(ta.value) const match = findActiveMention(ta.value, ta.selectionStart ?? 0) if (match) { setOpen(true) setActiveTrigger(match.trigger) setTriggerIndex(match.index) setQuery(match.query) setHighlightedIndex(0) onSearch?.({ trigger: match.trigger, query: match.query }) if (loadOptions) scheduleAsync(match.trigger, match.query) requestAnimationFrame(updateAnchor) } else { setOpen(false) } } function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) { if (!open || filtered.length === 0) return if (e.key === 'ArrowDown') { e.preventDefault() setHighlightedIndex((v) => (v + 1) % filtered.length) } else if (e.key === 'ArrowUp') { e.preventDefault() setHighlightedIndex((v) => (v - 1 + filtered.length) % filtered.length) } else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault() const opt = filtered[highlightedIndex] if (opt && !opt.disabled) insert(opt) } else if (e.key === 'Escape') { e.preventDefault() setOpen(false) } } function defaultFormat(option: O, _trigger: string) { return `${prefix}${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) }) } const anchorStyle: React.CSSProperties = caretRect ? { position: 'fixed', top: `${caretRect.top + caretRect.height}px`, left: `${caretRect.left}px`, width: '0px', height: '0px', pointerEvents: 'none', } : { display: 'none' } 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} 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="w-64 p-1" onOpenAutoFocus={(e) => e.preventDefault()} > {totalLoading ? ( <div className="text-muted-foreground px-2 py-3 text-sm">Loading...</div> ) : filtered.length === 0 ? ( <div className="text-muted-foreground px-2 py-3 text-sm">No matches</div> ) : ( <ul className="max-h-64 overflow-auto" role="listbox"> {filtered.map((opt, i) => ( <li key={opt.value} role="option" aria-selected={i === highlightedIndex} className={cn( 'flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm', i === highlightedIndex && !opt.disabled ? 'bg-accent text-accent-foreground' : '', opt.disabled ? 'cursor-not-allowed opacity-50' : '', )} onMouseEnter={() => setHighlightedIndex(i)} onMouseDown={(e) => { e.preventDefault() if (!opt.disabled) insert(opt) }} > {opt.avatar && <img src={opt.avatar} alt="" className="size-6 rounded-full" />} <div className="min-w-0 flex-1"> <div className="truncate">{opt.label}</div> {opt.description && ( <div className="text-muted-foreground truncate text-xs">{opt.description}</div> )} </div> </li> ))} </ul> )} </PopoverContent> </Popover> </div> ) } // forwardRef erases the generic; the cast restores the generic call signature so // consumers can still get type inference on `options`/`onSelect`. const Mentions = React.forwardRef(MentionsInner) as <O extends MentionOption = MentionOption>( props: MentionsProps<O> & { ref?: React.ForwardedRef<HTMLTextAreaElement> }, ) => React.ReactElement export { Mentions } -
components/ui/mentions/caret-position.ts 1.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.ts 0.1 kB
export { Mentions, type MentionsProps, type MentionOption } from './mentions'
Raw manifest: https://react.uipkge.dev/r/react/mentions.json