Mentions and commands
Type @ to pull in context or / for a command. The menu grows from the caret and your text becomes a chip.
Install with the shadcn CLI
$ npx shadcn@latest add https://hairlineui.com/r/mention-menu.jsonOverview
Agents work better when people can point at what they mean. This prompt field opens a small menu right at the caret when you type a trigger: @ for files, people and pages, / for commands. It narrows as you type, its height follows the results, and a highlight slides between rows. Enter or Tab turns the typed "@q3-sa" into a chip in place. Searches can be async, and earlier results stay up while the next ones load; with nothing to show, "Searching" morphs into "No matches for …". Closing, the menu sinks back toward the caret rather than vanishing.
States
- closed
- A plain prompt field with a placeholder. Enter sends, Shift+Enter adds a line.
- open
- A trigger after a space (or at a line’s start, for commands) grows the menu from the caret.
- filtering
- Results narrow and rank as you type, matching text is bold, and the menu’s height animates to fit.
- loading
- An async search shows "Searching" under a sweep of light until results arrive; on later keystrokes the old results stay and a small spinner fades in.
- empty
- "Searching" morphs into "No matches for “xyz”" as its spinner folds away. Escape closes the menu (it sinks away in 160ms) and leaves your text alone.
- chip
- The typed token becomes a tinted chip (or a mono command chip) where it was typed. Backspace removes it whole.
Usage
import { MentionInput } from "@/components/mention-menu";
<MentionInput
triggers={[
{ char: "@", label: "Context", items: files },
{ char: "/", label: "Commands", items: commands, startOnly: true, variant: "command" },
]}
onSubmit={({ text, mentions }) => send(text, mentions)}
/>Works with
With the AI SDK
Map the Vercel AI SDK’s message parts and chat status onto the component’s props.
"use client";
import { useRef } from "react";
import { useChat } from "@ai-sdk/react";
import { MentionInput, type MentionInputHandle } from "@/components/mention-menu";
export function Composer() {
const { sendMessage } = useChat();
const input = useRef<MentionInputHandle>(null);
return (
<MentionInput
ref={input}
triggers={[
{ char: "@", label: "Context", items: (q) => fetch(`/api/search?q=${encodeURIComponent(q)}`).then((r) => r.json()) },
{ char: "/", label: "Commands", items: COMMANDS, startOnly: true, variant: "command" },
]}
onSubmit={({ text, mentions }) => {
sendMessage(
{ text },
{
body: {
context: mentions.filter((m) => m.trigger === "@").map((m) => m.id),
command: mentions.find((m) => m.trigger === "/")?.id,
},
}
);
input.current?.clear();
}}
/>
);
}
// app/api/chat/route.ts: load what was mentioned into the prompt
import { convertToModelMessages, streamText } from "ai";
import { openai } from "@ai-sdk/openai";
export async function POST(req: Request) {
const { messages, context = [], command } = await req.json();
const docs = await Promise.all(context.map(loadDocument)); // your storage
const result = streamText({
model: openai("gpt-4.1"),
system: [command && COMMAND_PROMPTS[command], ...docs.map((d) => `<doc name="${d.name}">\n${d.text}\n</doc>`)]
.filter(Boolean)
.join("\n\n"),
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}Props
| Prop | Type | Description |
|---|---|---|
| triggers | { char; label; items; startOnly?; variant? }[] | Each opening character, its menu name, and its items or a search (sync or async) that gets the query. |
| onSubmit | (value: { text; mentions; segments }) => void | Enter sends. text writes mentions as "@label"; segments keep the order for rendering. |
| onChange | (value) => void | Called on every edit, e.g. to enable a send button. |
| defaultValue | MentionSegment[] | Starting text and chips, e.g. a draft. |
| placeholder | stringdefault "Ask anything" | Shown while the field is empty; also its label. |
| side | "top" | "bottom"default "top" | Open above the caret (composers at the bottom of the screen) or below it. |
| anchorRef | RefObject<HTMLElement> | Open clear of this element, e.g. your composer, so the menu never covers it. |
| ref | { focus(); clear() } | Focus the field, or clear it after sending. |
Accessibility and motion
- The field is a combobox: the open menu is a listbox and the active row is announced through aria-activedescendant.
- Arrow keys move, Enter or Tab picks, Escape closes; clicking a row keeps the caret in the field.
- Typing in an IME (Japanese, Chinese, Korean) never opens or submits mid-composition, and pasting always inserts plain text.
- @ only opens after a space or at the start, so email addresses don’t open the menu.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.
The CLI also installs Text morph.
Source
›Show the full source of mention-menu.tsx (588 lines)
"use client";
import * as React from "react";
import { TextMorph } from "./text-morph";
/* ─────────────────────────────────────────────────────────
* MENTIONS AND COMMANDS: @ for context, / for commands
*
* closed a plain prompt field
* open type a trigger and a menu grows from the caret
* filtering it narrows as you type; its height follows
* loading async search: "Searching…" (earlier results stay)
* empty "Searching" morphs into "No matches for …"
* closing the menu sinks back toward the caret, keeping what
* it showed, instead of vanishing
* chip Enter or Tab turns the typed "@q3-sa" into a chip
* right where it was typed
*
* A contentEditable field that reads like a textarea. Enter sends
* (Shift+Enter for a new line); onSubmit gets the text, the
* mentions and the ordered segments.
* ───────────────────────────────────────────────────────── */
export interface MentionItem {
id: string;
label: string;
description?: string;
icon?: React.ReactNode;
}
export interface MentionTrigger {
/** The character that opens the menu, e.g. "@" or "/". */
char: string;
/** The menu's name, e.g. "Context" or "Commands". */
label: string;
/** The items, or a search (sync or async) that gets the query. */
items: MentionItem[] | ((query: string) => MentionItem[] | Promise<MentionItem[]>);
/** Only at the start of a line, like slash commands. */
startOnly?: boolean;
/** How the chip looks: a tinted mention or a mono command. */
variant?: "mention" | "command";
}
export type MentionSegment = { type: "text"; text: string } | { type: "mention"; trigger: string; id: string; label: string };
export interface MentionValue {
/** Plain text, with mentions written as "@label". */
text: string;
mentions: { trigger: string; id: string; label: string }[];
segments: MentionSegment[];
}
export interface MentionInputHandle {
focus: () => void;
clear: () => void;
}
export interface MentionInputProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "onSubmit" | "defaultValue"> {
triggers: MentionTrigger[];
defaultValue?: MentionSegment[];
onChange?: (value: MentionValue) => void;
/** Enter sends; Shift+Enter adds a line. Without it, Enter adds a line. */
onSubmit?: (value: MentionValue) => void;
placeholder?: string;
disabled?: boolean;
/** Open the menu above the caret (composers at the bottom) or below it. */
side?: "top" | "bottom";
/** Open clear of this element (e.g. your composer) instead of over it. */
anchorRef?: React.RefObject<HTMLElement | null>;
ref?: React.Ref<MentionInputHandle>;
}
type Token = { node: Text; start: number; end: number };
type Menu = { trigger: MentionTrigger; query: string; left: number; top: number; bottom: number; width: number };
const EASE = "cubic-bezier(0.23,1,0.32,1)";
const MENU_WIDTH = 300;
const ROW = 36;
const MAX_ROWS = 5;
const CHIP =
"mx-px inline-block rounded-md bg-primary/10 px-1 align-baseline font-medium text-primary [&>span]:opacity-55";
const COMMAND_CHIP =
"mx-px inline-block rounded-md bg-muted px-1 align-baseline font-mono text-[0.92em] text-foreground [&>span]:opacity-45";
const ENTER = "animate-[ui-chip-in_280ms_cubic-bezier(0.23,1,0.32,1)_both] motion-reduce:animate-none";
const MORPH = "cubic-bezier(0.16,1,0.3,1)";
// A light that sweeps across the label. A mask, not a text clip, so it reaches letters that are mid-morph.
const SHEEN =
"text-foreground [mask-image:linear-gradient(90deg,rgb(0_0_0/0.45)_35%,#000_50%,rgb(0_0_0/0.45)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none] motion-reduce:text-foreground/70";
const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
const query = window.matchMedia(reducedQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);
function makeChip(trigger: MentionTrigger | { char: string; variant?: string }, id: string, label: string, animate: boolean) {
const chip = document.createElement("span");
chip.contentEditable = "false";
chip.dataset.mention = trigger.char;
chip.dataset.id = id;
chip.dataset.label = label;
chip.className = `${trigger.variant === "command" ? COMMAND_CHIP : CHIP}${animate ? ` ${ENTER}` : ""}`;
const mark = document.createElement("span");
mark.textContent = trigger.char;
chip.append(mark, label);
return chip;
}
/** Read the field back into ordered segments. Handles the <br> and <div> lines browsers insert. */
function serialize(root: HTMLElement): MentionSegment[] {
const segments: MentionSegment[] = [];
const text = (t: string) => {
if (!t) return;
const last = segments[segments.length - 1];
if (last?.type === "text") last.text += t;
else segments.push({ type: "text", text: t });
};
const walk = (parent: Node) => {
parent.childNodes.forEach((node, i) => {
if (node.nodeType === Node.TEXT_NODE) text((node as Text).data.replace(/ /g, " "));
else if (node instanceof HTMLElement) {
if (node.dataset.mention) segments.push({ type: "mention", trigger: node.dataset.mention, id: node.dataset.id ?? "", label: node.dataset.label ?? "" });
else if (node.tagName === "BR") text("\n");
else {
if (i > 0 && (node.tagName === "DIV" || node.tagName === "P")) text("\n");
walk(node);
}
}
});
};
walk(root);
const last = segments[segments.length - 1];
if (last?.type === "text") last.text = last.text.replace(/\n$/, "");
return segments.filter((s) => s.type === "mention" || s.text);
}
function toValue(segments: MentionSegment[]): MentionValue {
return {
segments,
text: segments.map((s) => (s.type === "text" ? s.text : `${s.trigger}${s.label}`)).join(""),
mentions: segments.flatMap((s) => (s.type === "mention" ? [{ trigger: s.trigger, id: s.id, label: s.label }] : [])),
};
}
/** Best matches first: label starts with the query, then a word in it does, then it appears anywhere. */
function rank(items: MentionItem[], query: string) {
const q = query.toLowerCase();
if (!q) return items;
const score = (item: MentionItem) => {
const label = item.label.toLowerCase();
if (label.startsWith(q)) return 0;
if (label.split(/[\s\-_./]+/).some((w) => w.startsWith(q))) return 1;
if (label.includes(q)) return 2;
if (item.description?.toLowerCase().includes(q)) return 3;
return -1;
};
return items
.map((item) => ({ item, s: score(item) }))
.filter((x) => x.s >= 0)
.sort((a, b) => a.s - b.s)
.map((x) => x.item);
}
function Highlight({ text, query }: { text: string; query: string }) {
const i = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;
if (i < 0) return <>{text}</>;
return (
<>
{text.slice(0, i)}
<span className="font-semibold text-foreground">{text.slice(i, i + query.length)}</span>
{text.slice(i + query.length)}
</>
);
}
export function MentionInput({
triggers,
defaultValue,
onChange,
onSubmit,
placeholder = "Ask anything",
disabled = false,
side = "top",
anchorRef,
className = "",
ref,
...props
}: MentionInputProps) {
const id = React.useId();
const rootRef = React.useRef<HTMLDivElement>(null);
const editorRef = React.useRef<HTMLDivElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const innerRef = React.useRef<HTMLDivElement>(null);
const rowRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const tokenRef = React.useRef<Token | null>(null);
const dismissedRef = React.useRef<{ node: Text; start: number } | null>(null);
const composingRef = React.useRef(false);
const [menu, setMenu] = React.useState<Menu | null>(null);
const [results, setResults] = React.useState<MentionItem[]>([]);
const [loading, setLoading] = React.useState(false);
const [active, setActive] = React.useState(0);
const [height, setHeight] = React.useState<number | null>(null);
const [highlight, setHighlight] = React.useState<{ top: number; ready: boolean }>({ top: 0, ready: false });
const emit = React.useCallback(() => {
const editor = editorRef.current;
if (!editor) return;
const segments = serialize(editor);
// Browsers leave a stray <br> behind when everything is deleted.
if (!segments.length && editor.childNodes.length) editor.replaceChildren();
onChange?.(toValue(segments));
}, [onChange]);
const closeMenu = React.useCallback(() => {
tokenRef.current = null;
setMenu(null);
}, []);
// Write the starting value once; after that the browser owns the field.
React.useLayoutEffect(() => {
const editor = editorRef.current;
if (!editor || !defaultValue?.length) return;
editor.replaceChildren(
...defaultValue.map((s) =>
s.type === "text"
? document.createTextNode(s.text)
: makeChip(triggers.find((t) => t.char === s.trigger) ?? { char: s.trigger }, s.id, s.label, false)
)
);
emit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* Is the caret just after a trigger and a query? Then open (or update) the menu at the trigger. */
const detect = React.useCallback(() => {
const editor = editorRef.current;
const root = rootRef.current;
const selection = window.getSelection();
if (!editor || !root || !selection || !selection.rangeCount || !selection.isCollapsed || composingRef.current) return closeMenu();
const node = selection.anchorNode;
if (!node || node.nodeType !== Node.TEXT_NODE || !editor.contains(node)) return closeMenu();
const textNode = node as Text;
const before = textNode.data.slice(0, selection.anchorOffset);
let found: { trigger: MentionTrigger; at: number } | null = null;
for (const trigger of triggers) {
const at = before.lastIndexOf(trigger.char);
if (at < 0 || (found && at < found.at)) continue;
const query = before.slice(at + 1);
if (/\s/.test(query) || query.length > 48) continue;
const previous = at > 0 ? before[at - 1] : null;
const sibling = textNode.previousSibling;
const lineStart = previous === "\n" || (at === 0 && (!sibling || (sibling instanceof HTMLElement && sibling.tagName === "BR")));
const boundary = lineStart || (previous !== null ? /\s/.test(previous) : true);
if (trigger.startOnly ? !lineStart : !boundary) continue;
found = { trigger, at };
}
const dismissed = dismissedRef.current;
if (!found || (dismissed && dismissed.node === textNode && dismissed.start === found.at)) return closeMenu();
dismissedRef.current = null;
tokenRef.current = { node: textNode, start: found.at, end: selection.anchorOffset };
const range = document.createRange();
range.setStart(textNode, found.at);
range.setEnd(textNode, found.at + 1);
const rect = range.getBoundingClientRect();
const box = root.getBoundingClientRect();
// Never wider than the field, and kept inside it.
const width = Math.min(MENU_WIDTH, box.width);
const left = Math.max(0, Math.min(rect.left - box.left - 10, box.width - width));
const { trigger, at } = found;
const edge = anchorRef?.current?.getBoundingClientRect();
const query = before.slice(at + 1);
setMenu((m) =>
m && m.trigger === trigger && m.query === query && m.left === left && m.width === width
? m
: {
trigger,
query,
left,
width,
// Clear of the anchor when there is one (so it never covers the composer), else of the caret's line.
top: (edge ? edge.bottom : rect.bottom) - box.top + 6,
bottom: box.bottom - (edge ? edge.top : rect.top) + 6,
}
);
}, [triggers, closeMenu, anchorRef]);
// Results for the current trigger and query; stale async answers are dropped.
const trigger = menu?.trigger;
const query = menu?.query ?? "";
React.useEffect(() => {
if (!trigger) {
setResults([]);
setLoading(false);
return;
}
let current = true;
const source = trigger.items;
const out = typeof source === "function" ? source(query) : rank(source, query);
if (out instanceof Promise) {
setLoading(true);
out.then(
(items) => current && (setResults(items), setLoading(false)),
() => current && (setResults([]), setLoading(false))
);
} else {
setResults(out);
setLoading(false);
}
return () => {
current = false;
};
}, [trigger, query]);
React.useEffect(() => setActive(0), [results]);
// The list's height follows its content.
React.useLayoutEffect(() => {
const inner = innerRef.current;
if (!menu || !inner) {
setHeight(null);
return;
}
const measure = () => setHeight(inner.offsetHeight);
measure();
const observer = new ResizeObserver(measure);
observer.observe(inner);
return () => observer.disconnect();
}, [menu !== null]); // eslint-disable-line react-hooks/exhaustive-deps
// The highlight slides to the active row, and the row stays in view.
React.useLayoutEffect(() => {
const row = rowRefs.current[active];
const list = listRef.current;
if (!row || !list) return;
const top = row.offsetTop;
setHighlight((h) => (h.top === top && h.ready ? h : { top, ready: h.ready || results.length > 0 }));
if (row.offsetTop < list.scrollTop) list.scrollTop = row.offsetTop - 4;
else if (row.offsetTop + row.offsetHeight > list.scrollTop + list.clientHeight) list.scrollTop = row.offsetTop + row.offsetHeight - list.clientHeight + 4;
}, [active, results]);
React.useEffect(() => {
if (!menu) setHighlight({ top: 0, ready: false });
}, [menu]);
// Follow the caret while the field has focus.
React.useEffect(() => {
const onSelection = () => {
if (document.activeElement === editorRef.current) detect();
};
document.addEventListener("selectionchange", onSelection);
return () => document.removeEventListener("selectionchange", onSelection);
}, [detect]);
const select = (item: MentionItem) => {
const token = tokenRef.current;
const editor = editorRef.current;
if (!token || !menu || !editor) return;
const { node, start } = token;
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, Math.min(token.end, node.length));
range.deleteContents();
const space = document.createTextNode(" ");
range.insertNode(space);
range.insertNode(makeChip(menu.trigger, item.id, item.label, true));
const caret = document.createRange();
caret.setStart(space, 1);
caret.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(caret);
closeMenu();
emit();
};
const submit = () => {
const editor = editorRef.current;
if (!editor || !onSubmit) return;
const segments = serialize(editor);
if (!segments.length) return;
onSubmit(toValue(segments));
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.nativeEvent.isComposing || composingRef.current) return;
if (menu) {
const count = results.length;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (count) setActive((a) => (a + (e.key === "ArrowDown" ? 1 : -1) + count) % count);
return;
}
if ((e.key === "Enter" || e.key === "Tab") && count) {
e.preventDefault();
select(results[active]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
if (tokenRef.current) dismissedRef.current = { node: tokenRef.current.node, start: tokenRef.current.start };
closeMenu();
return;
}
}
if (e.key === "Enter" && !e.shiftKey && onSubmit) {
e.preventDefault();
submit();
}
};
React.useImperativeHandle(
ref,
() => ({
focus: () => editorRef.current?.focus(),
clear: () => {
editorRef.current?.replaceChildren();
closeMenu();
emit();
},
}),
[closeMenu, emit]
);
const listId = `${id}-list`;
const optionId = (i: number) => `${id}-option-${i}`;
const reduced = useReducedMotion();
const menuRef = React.useRef<HTMLDivElement>(null);
// What the menu shows. While it closes it keeps showing the last of it, so it can sink away instead of vanishing.
const live = menu ? { menu, results, loading, active } : null;
const [last, setLast] = React.useState(live);
if (live && (live.menu !== last?.menu || live.results !== last.results || live.loading !== last.loading || live.active !== last.active)) setLast(live);
const view = live ?? last;
const closing = !live && last !== null;
React.useLayoutEffect(() => {
if (!closing) return;
const el = menuRef.current;
if (!el || reduced) {
setLast(null);
return;
}
const away = side === "top" ? 4 : -4;
const leave = el.animate(
[
{ opacity: 1, transform: "none" },
{ opacity: 0, transform: `translateY(${away}px) scale(0.97)` },
],
{ duration: 160, easing: "ease-out", fill: "forwards" }
);
leave.onfinish = () => setLast(null);
return () => leave.cancel();
}, [closing, reduced, side]);
const command = view?.menu.trigger.variant === "command";
return (
<div ref={rootRef} className={`relative ${className}`} {...props}>
<div
ref={editorRef}
role="combobox"
aria-label={placeholder}
aria-expanded={menu !== null}
aria-controls={menu ? listId : undefined}
aria-activedescendant={menu && results.length ? optionId(active) : undefined}
aria-autocomplete="list"
aria-disabled={disabled || undefined}
data-placeholder={placeholder}
contentEditable={!disabled}
suppressContentEditableWarning
spellCheck
onInput={() => {
emit();
detect();
}}
onKeyDown={onKeyDown}
onBlur={closeMenu}
onCompositionStart={() => (composingRef.current = true)}
onCompositionEnd={() => {
composingRef.current = false;
detect();
}}
onPaste={(e) => {
e.preventDefault();
document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
}}
className="max-h-52 min-h-6 overflow-y-auto whitespace-pre-wrap outline-none [overflow-wrap:anywhere] empty:before:pointer-events-none empty:before:float-left empty:before:h-0 empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)]"
/>
{view && (
<div
ref={menuRef}
inert={closing}
aria-hidden={closing || undefined}
className={`absolute z-50 overflow-hidden rounded-2xl bg-popover text-popover-foreground shadow-[0_0_0_1px_var(--border)] motion-reduce:animate-none ${
closing ? "pointer-events-none" : "animate-[ui-pop-in_200ms_cubic-bezier(0.23,1,0.32,1)_backwards]"
}`}
style={{
left: view.menu.left,
width: view.menu.width,
...(side === "top" ? { bottom: view.menu.bottom, transformOrigin: "bottom left" } : { top: view.menu.top, transformOrigin: "top left" }),
height: height ?? undefined,
transition: reduced ? "none" : `height 220ms ${EASE}`,
}}
// Keep the caret in the field while clicking the menu.
onPointerDown={(e) => e.preventDefault()}
>
<div ref={innerRef} className="p-1">
<div className="flex h-7 items-center justify-between px-2.5 text-[11.5px] text-muted-foreground">
<span>{view.menu.trigger.label}</span>
{/* Fades in while a search runs over results you can already see. */}
<span
aria-hidden="true"
className={`size-3 rounded-full border-[1.5px] border-border border-t-muted-foreground motion-reduce:animate-none ${view.loading && view.results.length > 0 ? "animate-spin" : ""}`}
style={{ opacity: view.loading && view.results.length > 0 ? 1 : 0, transition: reduced ? "none" : `opacity 240ms ${MORPH}` }}
/>
</div>
<div
ref={listRef}
id={listId}
role="listbox"
aria-label={view.menu.trigger.label}
className="relative overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
style={{ maxHeight: ROW * MAX_ROWS }}
>
{view.results.length > 0 && (
<span
aria-hidden="true"
className="absolute inset-x-0 top-0 h-9 rounded-xl bg-accent"
style={{
transform: `translateY(${highlight.top}px)`,
transition: highlight.ready && !reduced ? `transform 180ms ${EASE}` : "none",
}}
/>
)}
{view.results.map((item, i) => (
<div
key={item.id}
ref={(el) => {
rowRefs.current[i] = el;
}}
id={optionId(i)}
role="option"
aria-selected={i === view.active}
onPointerMove={() => i !== active && setActive(i)}
onClick={() => select(item)}
className="relative flex h-9 cursor-pointer items-center gap-2.5 rounded-xl px-2.5 text-[13px]"
>
{item.icon && (
<span aria-hidden="true" className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4 [&_svg]:stroke-[1.8]">
{item.icon}
</span>
)}
<span className={`max-w-[65%] shrink-0 truncate text-foreground/85 ${command ? "font-mono text-[12.5px]" : ""}`}>
{command && <span className="text-muted-foreground">{view.menu.trigger.char}</span>}
<Highlight text={item.label} query={view.menu.query} />
</span>
{item.description && <span className="min-w-0 flex-1 truncate text-[12px] text-muted-foreground">{item.description}</span>}
</div>
))}
{view.results.length === 0 && (
// One line: "Searching" morphs into "No matches for …" as the answer comes back.
<div role="status" className="flex h-9 items-center px-2.5 text-[12.5px] text-muted-foreground">
<span
aria-hidden="true"
className="flex shrink-0 items-center overflow-hidden"
style={{ width: view.loading ? 20 : 0, opacity: view.loading ? 1 : 0, transition: reduced ? "none" : `width 320ms ${MORPH}, opacity 240ms ${MORPH}` }}
>
<span className={`size-3 rounded-full border-[1.5px] border-border border-t-muted-foreground motion-reduce:animate-none ${view.loading ? "animate-spin" : ""}`} />
</span>
<span className={`min-w-0 truncate ${view.loading ? SHEEN : ""}`}>
<TextMorph>{view.loading ? "Searching" : view.menu.query ? `No matches for “${view.menu.query}”` : "Nothing here yet"}</TextMorph>
</span>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
Registry item: /r/mention-menu.json