Docs

React UI

Composer triggers

Add @ mentions, slash commands, variables, and custom inline entity chips to @anvia/react-ui composers.

Composer.Input is a rich text input backed by Tiptap. It stores the submitted plain text in composer state and can turn trigger matches such as @ada, /summarize, or $account into inline entities.

Use triggers when the user should select a known person, command, file, variable, tool, or other structured value while they write. Use Composer.TextareaInput when the surface needs the previous native textarea behavior and does not need inline entities.

Basic trigger

Pass trigger definitions to Composer.Root, render Composer.Input, and place Composer.TriggerMenu near the input. The default menu renders one Composer.TriggerItem per matched item.

import { Composer, type ComposerTriggerDefinition } from "@anvia/react-ui";

const triggers: ComposerTriggerDefinition[] = [
  {
    id: "people",
    char: "@",
    items: [
      { id: "user_ada", label: "Ada Lovelace", data: { type: "user" } },
      { id: "user_grace", label: "Grace Hopper", data: { type: "user" } },
    ],
  },
];

export function MentionComposer() {
  return (
    <Composer.Root triggers={triggers}>
      <Composer.Input placeholder="Mention a teammate..." />
      <Composer.TriggerMenu className="trigger-menu" />
      <Composer.Submit />
    </Composer.Root>
  );
}

Static items are filtered by label with the current query. Selecting Ada Lovelace inserts an inline entity chip and keeps the composer input text as @Ada Lovelace.

Multiple trigger characters

Each trigger has a stable id and one trigger char. Use separate definitions for different catalogs.

const triggers: ComposerTriggerDefinition[] = [
  {
    id: "people",
    char: "@",
    items: people.map((person) => ({
      id: person.id,
      label: person.name,
      data: { type: "person", email: person.email },
    })),
  },
  {
    id: "commands",
    char: "/",
    startOfLine: true,
    items: [
      { id: "summarize", label: "Summarize", text: "/summarize" },
      { id: "next_steps", label: "Next steps", text: "/next-steps" },
    ],
  },
  {
    id: "variables",
    char: "$",
    items: [
      { id: "account", label: "account.name", text: "$account.name" },
      { id: "plan", label: "subscription.plan", text: "$subscription.plan" },
    ],
  },
];

Use text when the plain text inserted into the prompt should differ from the item label. Without text, the composer inserts ${trigger.char}${item.label}.

Async items

Use a function when options come from local search, a route, or an app-owned index. The function can return items or a promise. Use signal to cancel in-flight work when the query changes.

const triggers: ComposerTriggerDefinition[] = [
  {
    id: "people",
    char: "@",
    minQueryLength: 2,
    items: async ({ query, signal }) => {
      const response = await fetch(`/api/people?q=${encodeURIComponent(query)}`, { signal });
      const people = (await response.json()) as Array<{ id: string; name: string; role: string }>;

      return people.map((person) => ({
        id: person.id,
        label: person.name,
        detail: person.role,
        data: { type: "person", role: person.role },
      }));
    },
  },
];

The item callback receives:

Field Use
trigger The active ComposerTriggerDefinition.
query Text typed after the trigger character.
input Current composer plain text.
entities Current selected entities in the composer.
signal Abort signal for cancelling async item loading.

Changing the triggers array rebuilds the Tiptap extension set and editor because trigger characters and suggestion plugins are extension configuration. Keeping definitions memoized can reduce editor churn when parent components rerender, especially with async item sources, but it is an optimization rather than a correctness requirement. Editor recreation, rapid trigger updates, and Strict Mode mount cycles are supported.

Trigger options

Option Use
id Stable identifier stored on submitted entities.
char Trigger character such as @, /, or $.
items Static items or an async item resolver.
minQueryLength Minimum query length before items are requested.
allowedPrefixes Prefix characters allowed before the trigger, or null for any prefix.
startOfLine Only open the trigger at the start of a line.
allowSpaces Keep the trigger active while the query contains spaces.

Custom menu

Composer.TriggerMenu exposes the active trigger state as a render prop. Use it when the menu needs section headings, loading states, item details, or product-specific markup.

<Composer.TriggerMenu className="trigger-menu">
  {(trigger) => (
    <>
      <div className="trigger-menu-header">
        {trigger.loading ? "Searching..." : `${trigger.trigger.char}${trigger.query}`}
      </div>
      {trigger.items.map((item, index) => (
        <Composer.TriggerItem className="trigger-menu-item" item={item} index={index} key={item.id}>
          {(option) => (
            <>
              <span>{option.label}</span>
              {option.detail ? <small>{option.detail}</small> : null}
            </>
          )}
        </Composer.TriggerItem>
      ))}
    </>
  )}
</Composer.TriggerMenu>

Arrow keys move through menu items, and Enter or Tab selects the active item. disabled items render disabled and cannot be selected.

Submitted entities

Selected entities are submitted with default composer metadata:

{
  "composer": {
    "entities": [
      {
        "id": "user_ada",
        "triggerId": "people",
        "trigger": "@",
        "label": "Ada Lovelace",
        "text": "@Ada Lovelace",
        "range": { "from": 0, "to": 13 },
        "data": { "type": "user" }
      }
    ]
  }
}

Entity ranges use JavaScript UTF-16 string offsets: text.slice(range.from, range.to) equals the entity’s text. This is the same indexing used by normal JavaScript strings, including text that contains emoji or other surrogate pairs. When default submission prefixes the prompt with a selected quote, Anvia shifts entity ranges by the exact generated prefix length so they stay aligned with the submitted text.

The metadata is strict JSON and survives the supported UI message to core message to JSON storage round trip. Applications do not need to reattach it after loading a conversation.

With a custom submit handler, read entities from the submit args and send them wherever your route expects them.

<Composer.Root
  triggers={triggers}
  submitMessage={async ({ input, entities, chat, clear }) => {
    await chat.sendMessage({
      text: input,
      metadata: {
        composer: { entities },
      },
    });
    clear();
  }}
>
  <Composer.Input />
  <Composer.TriggerMenu />
  <Composer.Submit />
</Composer.Root>

Use controlled entity props when another part of the app needs to inspect or clear selected entities before submit.

const [entities, setEntities] = useState<ComposerEntity[]>([]);

<Composer.Root
  triggers={triggers}
  entities={entities}
  onEntitiesChange={setEntities}
>
  <Composer.Input />
  <Composer.TriggerMenu />
</Composer.Root>;

For a complete copy-paste surface, see Composer triggers.