AI input
Mention menu
A prompt field where typing @ or / opens a menu that grows from the caret, follows the search with its height and a sliding highlight, and turns the picked item into a chip right where it was typed.
Live preview
Loading preview
A prompt field inside a composer with two triggers: @ for files (budget-2027.xlsx, roadmap-q4.md, team-notes.md and more) and / for commands at the start of a line (/summarize, /translate, /explain). Typing "@road" opens the menu with the match in bold; Enter turns it into a chip. A second trigger runs a slow async search to show Searching and No results. Replay types the demo text again.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add mention-menuUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { MentionMenu, type MentionMenuHandle, type MentionTrigger, type MentionValue } from './mention-menu';
const FILES = [
{ id: 'budget', label: 'budget-2027.xlsx', description: 'Spending by team' },
{ id: 'roadmap', label: 'roadmap-q4.md', description: 'Plan for Q4' },
{ id: 'roadmap-old', label: 'roadmap-archive.md', description: 'Older plans' },
{ id: 'notes', label: 'broadcast-notes.md', description: 'Launch call notes' },
{ id: 'brief', label: 'design-brief.pdf', description: 'Goals for the new app' },
{ id: 'faq', label: 'customer-faq.md', description: 'Common questions' },
];
const COMMANDS = [
{ id: 'summarize', label: 'summarize', description: 'Short summary of the text', icon: 'text' as const },
{ id: 'translate', label: 'translate', description: 'Into another language', icon: 'translate' as const },
{ id: 'explain', label: 'explain', description: 'In simple words', icon: 'idea' as const },
];
// A slow search, to show Searching and then No results.
const searchTickets = (query: string) =>
new Promise<{ id: string; label: string; icon: 'ticket' }[]>((resolve) =>
window.setTimeout(
() => resolve(query.startsWith('bug') ? [{ id: 'bug-42', label: 'bug-42', icon: 'ticket' }] : []),
900,
),
);
const TRIGGERS: MentionTrigger[] = [
{ char: '@', label: 'Files', items: FILES },
{ char: '/', label: 'Commands', items: COMMANDS, startOnly: true, variant: 'command' },
{ char: '#', label: 'Tickets', items: searchTickets },
];
const type = (field: HTMLElement, text: string) => {
const last = field.lastChild;
const node = last?.nodeType === 3 ? (last as Text) : field.appendChild(document.createTextNode(''));
(node as Text).data += text;
const range = document.createRange();
range.setStart(node, (node as Text).data.length);
range.collapse(true);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
field.dispatchEvent(new Event('input', { bubbles: true }));
};
const press = (field: HTMLElement, key: string) =>
field.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
export default function MentionMenuDemo({ replay = 0 }: { replay?: number }) {
const handle = React.useRef<MentionMenuHandle>(null);
const area = React.useRef<HTMLDivElement>(null);
const [value, setValue] = React.useState<MentionValue | null>(null);
// Replay types a message again: the file menu opens at "@road" and Enter makes a chip, then a slow ticket search finds nothing.
React.useEffect(() => {
if (replay === 0) return;
const field = area.current?.querySelector<HTMLElement>('[data-slot="mention-field"]');
if (!field) return;
handle.current?.clear();
field.focus();
const steps: [number, () => void][] = [];
let at = 400;
for (const ch of 'Compare @road') {
steps.push([at, () => type(field, ch)]);
at += 70;
}
at += 800;
steps.push([at, () => press(field, 'Enter')]);
at += 400;
for (const ch of ' with last year. See #roadm') {
steps.push([at, () => type(field, ch)]);
at += 50;
}
at += 2400;
steps.push([at, () => press(field, 'Escape')]);
const timers = steps.map(([when, run]) => window.setTimeout(run, when));
return () => {
for (const id of timers) window.clearTimeout(id);
};
}, [replay]);
return (
<div ref={area} className="flex w-full max-w-[520px] flex-col gap-3 pt-[220px]">
<MentionMenu
ref={handle}
triggers={TRIGGERS}
defaultValue={[
{ type: 'text', text: 'Compare ' },
{ type: 'mention', trigger: '@', id: 'budget', label: 'budget-2027.xlsx' },
{ type: 'text', text: ' with last year.' },
]}
onValueChange={setValue}
onSubmit={setValue}
/>
<p className="px-1 text-xs text-tertiary" aria-live="polite">
{value ? `Message text: ${value.text}` : 'Type @ for files, / for commands or # for tickets.'}
</p>
</div>
);
}
API
Parts: root, field (contentEditable combobox, reads like a textarea), placeholder, chip (mention or command, not editable), menu (floating surface, origin at the caret), menu header (trigger label + loading spinner), list (listbox, up to 5 rows visible), highlight (slides behind the active option), option (icon, label with the match in bold, description), status line (spinner slot + text that morphs).
| Prop | Type | Default | Description |
|---|---|---|---|
| triggers | Array<{ char: string; label: string; items: MentionItem[] | ((query: string) => MentionItem[] | Promise<MentionItem[]>); startOnly?: boolean; variant?: "mention" | "command" }> | - | One entry per trigger character, for example @ for files and / for commands. items is a list (ranked by the built-in match) or a search, sync or async, that gets the query. startOnly allows the trigger only at the start of a line. Required. |
| defaultValue | Array<{ type: "text"; text: string } | { type: "mention"; trigger: string; id: string; label: string }> | undefined | Starting content as ordered segments; written once, then the field owns its content. |
| placeholder | string | "Write a message" | Placeholder and accessible name of the field. |
| disabled | boolean | false | Turns off editing. |
| side | "top" | "bottom" | "top" | Opens the menu above the caret (composers at the bottom of a page) or below it. |
| anchor | element reference | undefined | An element the menu must not cover, for example the whole composer; the menu opens clear of it instead of next to the caret line. |
| Event | Description |
|---|---|
| valueChange | The content changed. Payload: { text, mentions, segments }; text writes each mention as its trigger and label, for example "@budget-2027.xlsx". |
| submit | Enter was pressed with the menu closed. Payload: the same value as valueChange. Without this listener Enter adds a line. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| MM1 | Trigger character typed (menu opens) | menu (origin: the caret corner) | opacity, translate y, scale | 0, distance-rise toward the caret, scale-press → 1, 0, 1 | duration-sm (200)ease-settle | Shows at once |
| MM2 | Menu closes (Escape, blur, pick, or the trigger is deleted) | menu | opacity, translate y, scale | 1, 0, 1 → 0, distance-rise toward the caret, scale-press | duration-xs (160)ease-exit | Hides at once |
| MM3 | Results change while typing | menu | height | old height → content height | duration-sm (200)ease-settle | Resizes at once |
| MM4 | Active option changes (arrow keys or pointer) | highlight | translate y | old row → new row | duration-xs (160)ease-settle | Moves at once |
| MM5 | Async search starts or ends with results shown | header spinner | opacity | 0 → 1 (back to 0 when the search ends) | duration-md (260)ease-out | Shows at once |
| MM6 | While searching | header spinner, status spinner | rotate | 0deg → 360deg, repeats | duration-6xl (900)ease-linear | No loop |
| MM7 | Search starts or ends with no results shown | status spinner slot | width | 0 → 20px (back to 0 when the search ends) | duration-lg (320)ease-out | Resizes at once |
| MM8 | Search starts or ends with no results shown | status spinner slot | opacity | 0 → 1 (back to 0 when the search ends) | duration-md (260)ease-out | Shows at once |
| MM9 | While searching with no results shown | status text | mask-position sheen | 150% → -50%, repeats | duration-loop (1600)ease-linear | No loop |
| MM10 | Search ends with no results | status text | text morph | Searching → No results for "roadm" | duration-3xl (460)ease-out | Swaps at once |
| MM11 | Option picked (Enter, Tab or click) | new chip | opacity, scale | 0.35, 0.9 → 1, 1 | duration-md (260)ease-settle | Shows at once |
Accessibility
- The field is a role="combobox" with aria-autocomplete="list", aria-expanded, aria-controls pointing to the listbox while open and aria-activedescendant on the active option; it is named by the placeholder.
- The list is a role="listbox" named by the trigger label (for example "Files"); each row is a role="option" with aria-selected on the active one.
- Keys while the menu is open: Arrow Up and Arrow Down move the active option and wrap; Enter or Tab picks it; Escape closes the menu and keeps it closed for that trigger until a new trigger is typed.
- Focus never leaves the field: pressing on the menu does not move focus, and picking puts the caret after the chip and a space.
- The status line is a role="status" region, so "Searching" and "No results" are announced.
- A closing menu is aria-hidden and inert while it fades.
- Chips are not editable; they are read as their trigger and label, for example "@budget-2027.xlsx", and Backspace removes a chip as one unit.
- Option rows are 36px tall (target at least 24px).