Mention Textarea
A textarea that suggests people, channels or anything else after a trigger character.
@drift-ui/mention-textareaRegistry JSONOn this page
Installation
The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.
API reference
MentionTextarea
Opens a suggestion list when the caret follows a trigger character. Arrow keys move through it, Enter or Tab inserts the trigger, the option's value and a space, and Escape closes it.
| Prop | Type | Default |
|---|---|---|
valuestringControlled text. | string | |
defaultValuestring= ""Initial value when uncontrolled. | string | "" |
onValueChange(value: string) => voidCalled when the text changes. | (value: string) => void | |
triggersRequiredRecord<string, MentionOption[]>Maps a trigger character to its options, for example { "@": people, "#": channels }. | Record<string, MentionOption[]> |
Other props go to the <textarea>.
MentionOption
One suggestion.
| Prop | Type | Default |
|---|---|---|
valueRequiredstringText inserted after the trigger. Typing filters on value and label. | string | |
labelRequiredstringText shown in the list. | string | |
descriptionstringSecond line under the label. | string |
Source
"use client"
import * as React from "react"
import { flushSync } from "react-dom"
import { cn } from "@/lib/utils"
import {
filterMentionOptions,
findActiveMention,
insertMention,
type MentionOption,
} from "@/lib/mention"
import { useControllableState } from "@/hooks/use-controllable-state"
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "@/components/ui/popover"
function MentionTextarea({
value,
defaultValue = "",
onValueChange,
triggers,
onKeyDown,
className,
...props
}: Omit<
React.ComponentProps<"textarea">,
"value" | "defaultValue" | "onChange" | "onSelect"
> & {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
triggers: Record<string, MentionOption[]>
}) {
const listId = React.useId()
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
const { value: text, setValue: setText } = useControllableState({
value,
defaultValue,
onChange: onValueChange,
})
const [caret, setCaret] = React.useState(0)
const [dismissedStart, setDismissedStart] = React.useState<number | null>(
null
)
const [highlightedIndex, setHighlightedIndex] = React.useState(0)
const mention = findActiveMention(text, caret, Object.keys(triggers))
const options = mention
? filterMentionOptions(triggers[mention.trigger], mention.query)
: []
const isOpen =
mention !== null && options.length > 0 && mention.start !== dismissedStart
const mentionKey = mention ? `${mention.start}:${mention.query}` : ""
const [previousMentionKey, setPreviousMentionKey] = React.useState(mentionKey)
if (mentionKey !== previousMentionKey) {
setPreviousMentionKey(mentionKey)
setHighlightedIndex(0)
}
function selectOption(option: MentionOption) {
if (!mention) return
const next = insertMention(text, mention, caret, option)
flushSync(() => {
setText(next.text)
setCaret(next.caret)
})
textareaRef.current?.setSelectionRange(next.caret, next.caret)
}
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
onKeyDown?.(event)
if (!isOpen || event.defaultPrevented) return
switch (event.key) {
case "ArrowDown":
case "ArrowUp": {
event.preventDefault()
const delta = event.key === "ArrowDown" ? 1 : -1
setHighlightedIndex(
(highlightedIndex + delta + options.length) % options.length
)
return
}
case "Enter":
case "Tab":
event.preventDefault()
selectOption(options[highlightedIndex])
return
case "Escape":
event.preventDefault()
setDismissedStart(mention.start)
return
}
}
return (
<Popover
open={isOpen}
onOpenChange={(open) => {
if (!open && mention) setDismissedStart(mention.start)
}}
>
<PopoverAnchor asChild>
<textarea
ref={textareaRef}
data-slot="mention-textarea"
role="combobox"
aria-autocomplete="list"
aria-expanded={isOpen}
aria-controls={isOpen ? listId : undefined}
aria-activedescendant={
isOpen ? `${listId}-${highlightedIndex}` : undefined
}
value={text}
onChange={(event) => {
setText(event.target.value)
setCaret(event.target.selectionStart)
}}
onSelect={(event) => setCaret(event.currentTarget.selectionStart)}
onKeyDown={handleKeyDown}
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
className
)}
{...props}
/>
</PopoverAnchor>
<PopoverContent
align="start"
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
if (event.target === textareaRef.current) event.preventDefault()
}}
className="w-64 p-1"
>
<ul id={listId} role="listbox" data-slot="mention-textarea-list">
{options.map((option, index) => (
<li
key={option.value}
id={`${listId}-${index}`}
role="option"
aria-selected={index === highlightedIndex}
onPointerDown={(event) => event.preventDefault()}
onPointerMove={() => setHighlightedIndex(index)}
onClick={() => selectOption(option)}
className="flex cursor-default flex-col rounded-sm px-2 py-1.5 text-sm aria-selected:bg-accent aria-selected:text-accent-foreground"
>
<span>{option.label}</span>
{option.description && (
<span className="text-xs text-muted-foreground">
{option.description}
</span>
)}
</li>
))}
</ul>
</PopoverContent>
</Popover>
)
}
export { MentionTextarea }