Mentions
FormControlled multiline text with local @mention suggestions and keyboard selection.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { Mentions, type MentionOption } from "@/components/ui/mentions";
import { useState } from "react";
const people: MentionOption[] = [
{ id: "alex", label: "Alex Morgan", description: "Design lead" },
{ id: "sam", label: "Sam Rivera", description: "Engineering" },
{ id: "jordan", label: "Jordan Lee", description: "Product" },
{ id: "inactive", label: "Inactive member", disabled: true },
];
export default function MentionsExample() {
const [value, setValue] = useState("");
const [last, setLast] = useState("");
return (
<div className="w-full max-w-lg space-y-7">
<div>
<h3 className="font-semibold">Bring the right people in</h3>
<p className="mt-1 text-sm text-muted-foreground">
Mention a teammate in your project update.
</p>
</div>
<Mentions
label="Project update"
value={value}
onValueChange={setValue}
options={people}
onMention={(option) => setLast(option.label)}
placeholder="What is happening? Type @ to invite someone into the conversation…"
/>
<p role="status" className="text-xs text-muted-foreground">
{last
? `Mentioned ${last}. No notification was sent.`
: "Suggestions are local to this preview."}
</p>
<Mentions
label="Archived update"
value="Thanks @alex for the thoughtful review."
onValueChange={() => {}}
options={people}
readOnly
/>
</div>
);
}Usage
Supply local people with unique whitespace-free ids and readable labels. Typing @ at the start of text or after whitespace opens suggestions for the current caret; matching searches ids and labels and skips disabled options. The first eight matches are shown. Selecting inserts @id plus a trailing space and preserves text on both sides of the caret. The result is plain text, with onMention for optional metadata capture. It does not send notifications, perform async searches, or enforce identity permissions.
import { Mentions, type MentionOption } from "@/components/ui/mentions";Shipped as editable source in src/components/ui/mentions.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/mentions.tsx
"use client";
import { useId, useRef, useState, type ComponentProps } from "react";
import { Label } from "@dashboardpack/core/components/ui/label";
import { cn } from "@dashboardpack/core/lib/utils";
export interface MentionOption {
id: string;
label: string;
description?: string;
disabled?: boolean;
}
export interface MentionQuery {
start: number;
end: number;
query: string;
}
export function getMentionQuery(
value: string,
caret: number,
): MentionQuery | null {
const before = value.slice(0, caret);
const match = /(?:^|\s)@([a-zA-Z0-9_-]*)$/.exec(before);
return match
? { start: caret - match[1].length - 1, end: caret, query: match[1] }
: null;
}
export interface MentionsProps
extends Omit<ComponentProps<"textarea">, "value" | "onChange" | "children"> {
label: string;
value: string;
onValueChange: (value: string) => void;
options: MentionOption[];
onMention?: (option: MentionOption) => void;
}
export function Mentions({
label,
value,
onValueChange,
options,
onMention,
disabled,
readOnly,
className,
id,
...props
}: MentionsProps) {
const generated = useId();
const inputId = id ?? generated;
const listId = `${inputId}-suggestions`;
const input = useRef<HTMLTextAreaElement>(null);
const [query, setQuery] = useState<MentionQuery | null>(null);
const [active, setActive] = useState(0);
const [dismissed, setDismissed] = useState(false);
const matches = options
.filter(
(option) =>
!option.disabled &&
(option.label
.toLowerCase()
.includes(query?.query.toLowerCase() ?? "") ||
option.id.toLowerCase().includes(query?.query.toLowerCase() ?? "")),
)
.slice(0, 8);
const open = !!query && !dismissed && !disabled && !readOnly;
const index = Math.min(active, Math.max(0, matches.length - 1));
const locate = () => {
const next = getMentionQuery(
input.current?.value ?? value,
input.current?.selectionStart ?? value.length,
);
if (
query?.start !== next?.start ||
query?.end !== next?.end ||
query?.query !== next?.query
) {
setQuery(next);
setActive(0);
setDismissed(false);
}
};
const choose = (option: MentionOption) => {
if (!query) return;
const inserted = `@${option.id} `;
const next =
value.slice(0, query.start) + inserted + value.slice(query.end);
onValueChange(next);
setQuery(null);
onMention?.(option);
requestAnimationFrame(() => {
input.current?.focus();
input.current?.setSelectionRange(
query.start + inserted.length,
query.start + inserted.length,
);
});
};
return (
<div className={cn("relative min-w-0 space-y-2", className)}>
<Label htmlFor={inputId}>{label}</Label>
<textarea
{...props}
ref={input}
id={inputId}
value={value}
disabled={disabled}
readOnly={readOnly}
aria-autocomplete="list"
aria-controls={open ? listId : undefined}
aria-activedescendant={
open && matches.length ? `${listId}-${index}` : undefined
}
aria-describedby={`${inputId}-hint`}
onChange={(event) => {
onValueChange(event.target.value);
locate();
}}
onSelect={locate}
onBlur={() => setDismissed(true)}
onKeyDown={(event) => {
props.onKeyDown?.(event);
if (event.defaultPrevented || event.nativeEvent.isComposing || !open)
return;
if (event.key === "Escape") {
event.preventDefault();
setDismissed(true);
}
if (
matches.length &&
(event.key === "ArrowDown" || event.key === "ArrowUp")
) {
event.preventDefault();
setActive(
(index + (event.key === "ArrowDown" ? 1 : -1) + matches.length) %
matches.length,
);
}
if (matches.length && event.key === "Enter") {
event.preventDefault();
choose(matches[index]);
}
}}
className="min-h-28 w-full rounded-xl border border-input bg-background p-3 text-sm leading-6 outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
/>
<p id={`${inputId}-hint`} className="text-xs text-muted-foreground">
Type @ to mention someone. Use arrow keys and Enter to choose; Escape
closes suggestions.
</p>
{open && (
<div className="absolute start-0 top-full z-20 mt-1 w-full max-w-sm overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg">
<ul
id={listId}
role="listbox"
aria-label="Mention suggestions"
className="max-h-64 overflow-auto p-1"
>
{matches.map((option, position) => (
<li
key={option.id}
id={`${listId}-${position}`}
role="option"
aria-selected={position === index}
onPointerDown={(event) => event.preventDefault()}
onClick={() => choose(option)}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-md p-3",
position === index && "bg-accent text-accent-foreground",
)}
>
<span
aria-hidden
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary"
>
{option.label
.split(" ")
.map((part) => part[0])
.slice(0, 2)
.join("")}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">
{option.label}
</span>
<span className="block text-xs text-muted-foreground">
@{option.id}
{option.description ? ` · ${option.description}` : ""}
</span>
</span>
</li>
))}
</ul>
{matches.length === 0 && (
<p role="status" className="p-3 text-sm text-muted-foreground">
No people found.
</p>
)}
</div>
)}
</div>
);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Visible textarea label. |
| value | stringDefault: required | Controlled plain-text content. |
| onValueChange | (value: string) => voidDefault: required | Apply edits and inserted mentions. |
| options | MentionOption[]Default: required | Unique id, label, optional description, and disabled flag. |
| onMention | (option: MentionOption) => voidDefault: — | Observe a chosen person; does not send notifications. |
| disabled / readOnly | booleanDefault: false | Disable editing or preserve readable archived text. |
| ...props | textarea attributesDefault: — | Native textarea settings, excluding the controlled value/onChange contract. |