Composer Trigger Popover

Reusable picker UI for @ mentions, / slash commands, and any other character-triggered popover.

Mention — directive behavior
Slash — action behavior
Back

Getting Started

Add composer-trigger-popover

npx shadcn@latest add https://r.assistant-ui.com/composer-trigger-popover.json

This adds /components/assistant-ui/composer-trigger-popover.tsx — a generic picker UI (Categories + Items + Back) driven by an adapter and one of two behavior props: directive (insert a chip) or action (run a callback).

Wrap the composer

Place ComposerPrimitive.Unstable_TriggerPopoverRoot around your composer. Any number of ComposerTriggerPopover declarations can live inside — each with its own trigger character, adapter, and behavior prop.

components/assistant-ui/thread.tsx
import { ComposerPrimitive } from "@assistant-ui/react";
import { ComposerTriggerPopover } from "@/components/assistant-ui/composer-trigger-popover";

const Composer = () => (
  <ComposerPrimitive.Unstable_TriggerPopoverRoot>
    <ComposerPrimitive.Root>
      <ComposerPrimitive.Input placeholder="Type @ to mention, / for commands..." />
      <ComposerPrimitive.Send />

      {/* triggers declared here */}
    </ComposerPrimitive.Root>
  </ComposerPrimitive.Unstable_TriggerPopoverRoot>
);

@ Mention

Pair the popover with unstable_useMentionAdapter — the hook returns a spreadable { adapter, directive } bundle so selecting an item writes a :tool[Label]{name=id} directive into the composer text.

import { unstable_useMentionAdapter } from "@assistant-ui/react";
import { WrenchIcon } from "lucide-react";

const mention = unstable_useMentionAdapter();

<ComposerTriggerPopover
  char="@"
  {...mention}
  fallbackIcon={WrenchIcon}
/>;

Override formatter or add an onInserted callback via hook options: unstable_useMentionAdapter({ formatter, onInserted }).

unstable_useMentionAdapter also accepts items (flat custom list), categories (multi-category drill-down), and includeModelContextTools for fine-grained control. See the Mentions guide.

Render selected mentions as chips in user messages with DirectiveText. For inline chips inside the composer, use LexicalComposerInput.

/ Slash Command

Use unstable_useSlashCommandAdapter to bundle commands (data + execute) into { adapter, action } — then plug both into ComposerTriggerPopover. By default a directive chip is left in the composer as an audit trail; pass removeOnExecute to strip the /command text entirely. iconMap maps metadata.icon strings on items and categories to Lucide icons.

import {
  unstable_useSlashCommandAdapter,
  type Unstable_SlashCommand,
} from "@assistant-ui/react";
import { FileTextIcon, GlobeIcon, LanguagesIcon, SlashIcon } from "lucide-react";

const SLASH_COMMANDS: readonly Unstable_SlashCommand[] = [
  {
    id: "summarize",
    description: "Summarize the conversation",
    icon: "FileText",
    execute: () => {/* ... */},
  },
  {
    id: "translate",
    description: "Translate to another language",
    icon: "Languages",
    execute: () => {/* ... */},
  },
  {
    id: "search",
    description: "Search the web",
    icon: "Globe",
    execute: () => {/* ... */},
  },
];

function SlashComposer() {
  const slash = unstable_useSlashCommandAdapter({ commands: SLASH_COMMANDS });

  return (
    <ComposerTriggerPopover
      char="/"
      {...slash}
      iconMap={{
        FileText: FileTextIcon,
        Languages: LanguagesIcon,
        Globe: GlobeIcon,
      }}
      fallbackIcon={SlashIcon}
    />
  );
}

Combining Triggers

Multiple popovers coexist under one TriggerPopoverRoot. Each reads state from its own declaration, so @ and / never collide.

const commandHandlers: Record<string, () => void> = {
  summarize: () => {/* ... */},
  translate: () => {/* ... */},
};

<ComposerPrimitive.Unstable_TriggerPopoverRoot>
  <ComposerPrimitive.Root>
    <ComposerPrimitive.Input placeholder="Type @ to mention, / for commands..." />

    <ComposerTriggerPopover
      char="@"
      adapter={mentionAdapter}
      directive={{ formatter: unstable_defaultDirectiveFormatter }}
      fallbackIcon={WrenchIcon}
    />
    <ComposerTriggerPopover
      char="/"
      adapter={slashAdapter}
      action={{
        formatter: unstable_defaultDirectiveFormatter,
        onExecute: (item) => commandHandlers[item.id]?.(),
      }}
      iconMap={slashIcons}
      fallbackIcon={SlashIcon}
    />
  </ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>

Keyboard Navigation

KeyAction
ArrowDownHighlight next item
ArrowUpHighlight previous item
Enter / TabSelect highlighted item / drill into category
Shift + EnterInsert newline (popover stays open)
Shift + TabPass through (native focus traversal)
EscapeClose popover
BackspaceGo back to categories (when query is empty)

Accessibility

The popover implements the WAI-ARIA editable combobox pattern.

  • The listbox container has role="listbox" and each item has role="option" plus aria-selected.
  • When a popover is open, ComposerPrimitive.Input (the underlying <textarea>) automatically receives aria-controls, aria-expanded="true", aria-haspopup="listbox", and aria-activedescendant pointing at the highlighted option. Attributes are removed when the popover closes.
  • When ComposerPrimitive.Input is rendered outside a TriggerPopoverRoot, no ARIA attributes are added.

API Reference

PropTypeDefaultDescription
charstringTrigger character, e.g. "@" or "/" (required; unique within the root)
adapterUnstable_TriggerAdapterProvides categories, items, and search (required)
directive{ formatter, onInserted?, chip? }Enables directive-insert behavior. Mutually exclusive with action.
action{ formatter, onExecute, removeOnExecute?, chip? }Enables action behavior. Mutually exclusive with directive.
iconMapRecord<string, IconComponent>Maps item.metadata.icon / category.metadata.icon strings to icons
fallbackIconIconComponentSparklesIconIcon used when no iconMap entry matches
backLabelstring"Back"Back button label
emptyCategoriesLabelstring"No items available"Shown when no categories are available
emptyItemsLabelstring"No matching items"Shown when no items match

All other props (className, etc.) forward to the underlying popover div.

directive object

FieldTypeDescription
formatterUnstable_DirectiveFormatterSerializes the selected item into the directive text written to the composer
onInserted(item) => voidOptional callback fired after the directive has been inserted

action object

FieldTypeDescription
formatterUnstable_DirectiveFormatterSerializes the selected item into the chip left behind (unused when removeOnExecute)
onExecute(item) => voidCallback fired when an item is selected
removeOnExecutebooleanWhen true, strips the trigger text instead of leaving a chip. Default false.