
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
mentionsuiTextarea with trigger-character autocomplete and companion MentionTag with Twitter/X-style hover profile card popup.
Also available for Vue ->$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.jsonnpx shadcn@latest add @uipkge-react/mentionsInstalls to:components/ui/mentions/| 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 |
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
}'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 Mentions
'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 React.HTMLAttributes<HTMLElement> {
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>
)
}
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
}
export { Mentions, type MentionsProps, type MentionOption } from './mentions'
export { MentionTag, type MentionTagProps } from './mention-tag'
Raw manifest:https://uipkge.dev/r/react/mentions.json