Skip to content
ComposerMention menuMenu

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.

Recent first, fuzzy matches, a locked connector, no matches and searching.

Installation

$ pnpm dlx shadcn@latest add https://chatcn.dev/r/mention-menu.json

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.

tsx
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.

The menu anchored to a plain textarea caret, inserting the path as text.

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

PropTypeNotes
itemsMentionItem[]id, kind, label, detail, value, keywords, group, icon, disabled, disabledReason
querystringText typed after the @
onSelect(item) => void
open, onOpenChangeEscape and outside picks call onOpenChange(false)
anchor() => CaretRect | nullWithout it the menu renders in place
side"auto" | "top" | "bottom"Default auto. Picked once per opening
recentstring[]Shown first under Recent while the query is empty
loadingbooleanDefault false. Searching row under current results
filterbooleanDefault true. Turn off when your search already filtered
limitnumberDefault 50
menuRefRef<MentionMenuHandle>onKeyDown(event) returns true when it used the key
onActiveChange(id?: string) => voidFor aria-activedescendant on your input
width, maxHeightnumberDefault 320
emptyHintReactNodeSecond line of the no-matches state

Keyboard

KeyAction
ArrowDown, ArrowUp, Ctrl+N, Ctrl+PMove, looping, skipping disabled rows
Enter, TabPick the active row
EscClose

Accessibility

  • A listbox of options in labelled groups. Focus stays in the input; the active option is exposed through aria-activedescendant.
  • Locked rows are aria-disabled and 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.