Lunia DS

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.

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-menu

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
triggersArray<{ 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.
defaultValueArray<{ type: "text"; text: string } | { type: "mention"; trigger: string; id: string; label: string }>undefinedStarting content as ordered segments; written once, then the field owns its content.
placeholderstring"Write a message"Placeholder and accessible name of the field.
disabledbooleanfalseTurns off editing.
side"top" | "bottom""top"Opens the menu above the caret (composers at the bottom of a page) or below it.
anchorelement referenceundefinedAn element the menu must not cover, for example the whole composer; the menu opens clear of it instead of next to the caret line.
EventDescription
valueChangeThe content changed. Payload: { text, mentions, segments }; text writes each mention as its trigger and label, for example "@budget-2027.xlsx".
submitEnter 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
MM1Trigger character typed (menu opens)menu (origin: the caret corner)opacity, translate y, scale0, distance-rise toward the caret, scale-press → 1, 0, 1duration-sm (200)ease-settleShows at once
MM2Menu closes (Escape, blur, pick, or the trigger is deleted)menuopacity, translate y, scale1, 0, 1 → 0, distance-rise toward the caret, scale-pressduration-xs (160)ease-exitHides at once
MM3Results change while typingmenuheightold height → content heightduration-sm (200)ease-settleResizes at once
MM4Active option changes (arrow keys or pointer)highlighttranslate yold row → new rowduration-xs (160)ease-settleMoves at once
MM5Async search starts or ends with results shownheader spinneropacity0 → 1 (back to 0 when the search ends)duration-md (260)ease-outShows at once
MM6While searchingheader spinner, status spinnerrotate0deg → 360deg, repeatsduration-6xl (900)ease-linearNo loop
MM7Search starts or ends with no results shownstatus spinner slotwidth0 → 20px (back to 0 when the search ends)duration-lg (320)ease-outResizes at once
MM8Search starts or ends with no results shownstatus spinner slotopacity0 → 1 (back to 0 when the search ends)duration-md (260)ease-outShows at once
MM9While searching with no results shownstatus textmask-position sheen150% → -50%, repeatsduration-loop (1600)ease-linearNo loop
MM10Search ends with no resultsstatus texttext morphSearching → No results for "roadm"duration-3xl (460)ease-outSwaps at once
MM11Option picked (Enter, Tab or click)new chipopacity, scale0.35, 0.9 → 1, 1duration-md (260)ease-settleShows 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).