Mention menu
The @ popup for files, folders, symbols, people, agents and connectors: grouped, fuzzy filtered with the matched characters emphasized, recent first, anchored to the caret.
On this page
Installation
Usage
RichPromptInput wires this menu for you. Use it directly with your own editor or a plain textarea: give it the caret rect through anchor, forward keys through menuRef, and insert what onSelect returns.
import { MentionMenu, type MentionMenuHandle } from "@/components/ui/chatcn/mention-menu"
import { useChatcnMentionTrigger } from "@/hooks/use-chatcn-mention-trigger"
const menuRef = React.useRef<MentionMenuHandle>(null)
const { match, anchor, dismiss, complete } = useChatcnMentionTrigger(textareaRef, { value })
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (!e.nativeEvent.isComposing && match && menuRef.current?.onKeyDown(e)) e.preventDefault()
}}
/>
<MentionMenu
open={Boolean(match)}
onOpenChange={(open) => !open && dismiss()}
anchor={anchor}
query={match?.query ?? ""}
items={items}
recent={recentIds}
menuRef={menuRef}
onSelect={(item) => setValue(complete(`@${item.value ?? item.label}`))}
/>Examples
On a textarea
The terminal style: the pick becomes plain @src/server/runs.ts text. useChatcnMentionTrigger measures the caret with an off-screen mirror of the textarea.
Build your own menu
The parts are exported: MentionMenuPopup (positioning, listbox, active row and the key handle), MentionMenuGroup, MentionMenuItem, MentionMenuHighlight, MentionMenuEmpty and MentionMenuLoading. Rows are found in the DOM, so any markup you compose from them navigates correctly. SlashMenu is built this way.
Props
| Prop | Type | Notes |
|---|---|---|
items | MentionItem[] | id, kind, label, detail, value, keywords, group, icon, disabled, disabledReason |
query | string | Text typed after the @ |
onSelect | (item) => void | |
open, onOpenChange | Escape and outside picks call onOpenChange(false) | |
anchor | () => CaretRect | null | Without it the menu renders in place |
side | "auto" | "top" | "bottom" | Default auto. Picked once per opening |
recent | string[] | Shown first under Recent while the query is empty |
loading | boolean | Default false. Searching row under current results |
filter | boolean | Default true. Turn off when your search already filtered |
limit | number | Default 50 |
menuRef | Ref<MentionMenuHandle> | onKeyDown(event) returns true when it used the key |
onActiveChange | (id?: string) => void | For aria-activedescendant on your input |
width, maxHeight | number | Default 320 |
emptyHint | ReactNode | Second line of the no-matches state |
Keyboard
| Key | Action |
|---|---|
ArrowDown, ArrowUp, Ctrl+N, Ctrl+P | Move, looping, skipping disabled rows |
Enter, Tab | Pick the active row |
Esc | Close |
Accessibility
- A
listboxofoptions in labelledgroups. Focus stays in the input; the active option is exposed througharia-activedescendant. - Locked rows are
aria-disabledand say why they can't be picked. - Default Empty and searching states sit in a polite live region outside the listbox.. Rows grow to 44 px on touch screens. In forced colors the active row keeps an outline.
Motion
- None. The menu opens from typing, so it appears and updates instantly, and the active row moves without a transition.