A textarea with @mention autocomplete: an anchored, keyboard-navigable popover of teammates that completes the token and restores the caret.
npx shadcn@latest add @paragon/mention-inputAlso installs: popover
"use client";
import * as React from "react";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "@/registry/paragon/ui/popover";
import { cn } from "@/lib/utils";
export interface MentionUser {
id: string;
name: string;
/** Shown under the name, e.g. a handle or role. */
handle?: string;
}
export interface MentionInputProps
extends Omit<
React.ComponentProps<"textarea">,
"value" | "defaultValue" | "onChange"
> {
users?: MentionUser[];
defaultValue?: string;
onValueChange?: (value: string) => void;
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "•";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function hueOf(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash) % 360;
}
const DEFAULT_USERS: MentionUser[] = [
{ id: "priya", name: "Priya Raghavan", handle: "@priya · Identity" },
{ id: "marcus", name: "Marcus Kane", handle: "@marcus · Platform" },
{ id: "jamie", name: "Jamie Lin", handle: "@jamie · Growth" },
{ id: "dana", name: "Dana Ortiz", handle: "@dana · Security" },
{ id: "sofia", name: "Sofia Marin", handle: "@sofia · Design" },
{ id: "noah", name: "Noah Bennett", handle: "@noah · Support" },
];
/** Find an active "@token" ending at the caret, if any. */
function activeMention(
value: string,
caret: number,
): { start: number; query: string } | null {
const upto = value.slice(0, caret);
const match = upto.match(/(^|\s)@([\w-]*)$/);
if (!match) return null;
const query = match[2];
const start = caret - query.length - 1; // include the "@"
return { start, query };
}
/**
* A textarea with @mention autocomplete. As you type "@" followed by a name,
* a popover of matching people opens, pinned to the field; arrow keys move a
* highlight, Enter/Tab completes the token, Escape dismisses. Completion
* splices the chosen name back into the text and restores the caret after it.
* Matching is on name and handle. Selection is index-driven, so it never
* depends on hover.
*/
export function MentionInput({
users = DEFAULT_USERS,
defaultValue = "",
onValueChange,
className,
placeholder = "Write a comment… use @ to mention a teammate",
rows = 3,
onKeyDown,
...props
}: MentionInputProps) {
const [value, setValue] = React.useState(defaultValue);
const [open, setOpen] = React.useState(false);
const [active, setActive] = React.useState(0);
const [mention, setMention] = React.useState<{
start: number;
query: string;
} | null>(null);
const ref = React.useRef<HTMLTextAreaElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const matches = React.useMemo(() => {
if (!mention) return [];
const q = mention.query.toLowerCase();
return users
.filter((u) =>
`${u.name} ${u.handle ?? ""}`.toLowerCase().includes(q),
)
.slice(0, 6);
}, [users, mention]);
const sync = (next: string, caret: number) => {
setValue(next);
onValueChange?.(next);
const found = activeMention(next, caret);
setMention(found);
setOpen(!!found);
setActive(0);
};
React.useEffect(() => {
if (!open) return;
listRef.current
?.querySelector<HTMLElement>(`[data-index="${active}"]`)
?.scrollIntoView({ block: "nearest" });
}, [active, open]);
const complete = (user: MentionUser) => {
if (!mention) return;
const before = value.slice(0, mention.start);
const after = value.slice(mention.start + 1 + mention.query.length);
const insert = `@${user.name} `;
const next = before + insert + after;
const caret = (before + insert).length;
setValue(next);
onValueChange?.(next);
setOpen(false);
setMention(null);
requestAnimationFrame(() => {
const el = ref.current;
if (el) {
el.focus();
el.setSelectionRange(caret, caret);
}
});
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
onKeyDown?.(event);
if (open && matches.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
setActive((i) => (i + 1) % matches.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActive((i) => (i - 1 + matches.length) % matches.length);
return;
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
complete(matches[active]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
setMention(null);
return;
}
}
};
return (
<Popover open={open && matches.length > 0} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div className="w-full max-w-md">
<textarea
ref={ref}
value={value}
rows={rows}
placeholder={placeholder}
onChange={(event) =>
sync(event.target.value, event.target.selectionStart)
}
onKeyDown={handleKeyDown}
className={cn(
"w-full resize-none rounded-lg bg-card px-3 py-2.5 text-sm text-card-foreground shadow-border outline-none",
"placeholder:text-muted-foreground",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
{...props}
/>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
sideOffset={6}
onOpenAutoFocus={(event) => event.preventDefault()}
className="w-64 p-1"
>
<div
ref={listRef}
role="listbox"
aria-label="Mention a teammate"
className="max-h-60 overflow-y-auto [scrollbar-width:thin]"
>
{matches.map((user, index) => {
const isActive = index === active;
return (
<button
key={user.id}
type="button"
role="option"
aria-selected={isActive}
data-index={index}
onMouseEnter={() => setActive(index)}
onClick={() => complete(user)}
className={cn(
"flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left outline-none",
isActive ? "bg-accent text-accent-foreground" : "text-foreground",
)}
>
<span
className="flex size-6 shrink-0 items-center justify-center rounded-full text-[10px] font-medium"
style={{
backgroundColor: `oklch(0.9 0.05 ${hueOf(user.name)})`,
color: `oklch(0.4 0.09 ${hueOf(user.name)})`,
}}
>
{initials(user.name)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{user.name}
</span>
{user.handle && (
<span className="block truncate text-xs text-muted-foreground">
{user.handle}
</span>
)}
</span>
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}