Composer

Build custom message input UIs with full control over layout and behavior.

The Composer primitive is the interface for composing new messages or editing existing ones. It handles submit behavior, keyboard shortcuts, focus management, attachment state, and streaming status. You provide the UI.

Quick Start

Minimal example:

import { ComposerPrimitive } from "@assistant-ui/react";

<ComposerPrimitive.Root>
  <ComposerPrimitive.Input placeholder="Ask anything..." />
  <ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
</ComposerPrimitive.Root>

Root renders a <form>, Input renders a <textarea>, and Send renders a <button>. Each part renders its native element by default, and supports asChild for element substitution.

Info

Runtime setup: primitives require runtime context. Wrap your UI in AssistantRuntimeProvider with a runtime (for example useLocalRuntime(...)). See Pick a Runtime.

Core Concepts

New Message vs Edit Mode

A Composer placed inside a Thread composes new messages. A Composer placed inside a Message edits that message. The same primitives handle both, and the behavior follows the context.

// New message composer
<ThreadPrimitive.Root>
  <ComposerPrimitive.Root>
    <ComposerPrimitive.Input />
    <ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
  </ComposerPrimitive.Root>
</ThreadPrimitive.Root>

// Edit composer (inside a message)
<MessagePrimitive.Root>
  <ComposerPrimitive.Root>
    <ComposerPrimitive.Input />
    <ComposerPrimitive.Send>Save</ComposerPrimitive.Send>
    <ComposerPrimitive.Cancel>Cancel</ComposerPrimitive.Cancel>
  </ComposerPrimitive.Root>
</MessagePrimitive.Root>

The asChild Pattern

Every part that renders an element accepts asChild to merge its behavior onto your own element. This is how you use primitives with your own design system:

import { ComposerPrimitive } from "@assistant-ui/react";

<ComposerPrimitive.Input asChild>
  <textarea
    className="my-custom-textarea"
    placeholder="Type here..."
  />
</ComposerPrimitive.Input>

<ComposerPrimitive.Send asChild>
  <MyButton variant="primary">Send</MyButton>
</ComposerPrimitive.Send>

The primitive's behavior (keyboard handling, disabled state, form submission) is merged onto your element.

Info

Own the input DOM entirely, such as a contentEditable surface or editor library that cannot be expressed through asChild or render? See Headless Composer Input for the unstable hook that supplies composer text and send gating without ComposerPrimitive.Input.

Trigger Popovers

Composer includes stable trigger popover primitives and types for character-triggered popovers (e.g. @ for mentions, / for slash commands). Multiple triggers coexist under a single TriggerPopoverRoot. The helper hooks for mention, slash command, and live completion adapters, composer input, ARIA props, and trigger popover contexts remain unstable.

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

    {/* @ mention — inserts directive text into the message */}
    <ComposerPrimitive.TriggerPopover
      char="@"
      adapter={mentionAdapter}
    >
      <ComposerPrimitive.TriggerPopover.Directive formatter={formatter} />
      {/* popover UI */}
    </ComposerPrimitive.TriggerPopover>

    {/* / slash command — runs a handler on selection; leaves an audit-trail chip by default */}
    <ComposerPrimitive.TriggerPopover
      char="/"
      adapter={slashAdapter}
    >
      <ComposerPrimitive.TriggerPopover.Action
        formatter={formatter}
        onExecute={(item) => commandHandlers[item.id]?.()}
      />
      <ComposerPrimitive.TriggerPopoverItems>
        {(items) => items.map(item => (
          <ComposerPrimitive.TriggerPopoverItem key={item.id} item={item}>
            {item.label}
          </ComposerPrimitive.TriggerPopoverItem>
        ))}
      </ComposerPrimitive.TriggerPopoverItems>
    </ComposerPrimitive.TriggerPopover>
  </ComposerPrimitive.Root>
</ComposerPrimitive.TriggerPopoverRoot>

See the Mentions guide and Slash Commands guide for full documentation.

Parts

Root

Form container for message composition. Renders a <form> element unless asChild is set.

<ComposerPrimitive.Root className="flex w-full flex-col rounded-3xl border bg-muted">
  <ComposerPrimitive.Input placeholder="Ask anything..." />
  <ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
</ComposerPrimitive.Root>

Compact Mode

Pass compact to opt into compact detection. While the input holds at most a single line of text and the composer has no attachments, quote, queued messages, or active dictation, the root renders a data-compact attribute. The prop only exposes this attribute for styling and does not change the layout by itself. Target it with CSS to collapse the composer into a single row:

<ComposerPrimitive.Root
  compact
  className="group/composer flex flex-col data-[compact]:flex-row data-[compact]:items-center"
>
  <ComposerPrimitive.Input placeholder="Ask anything..." />
  <ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
</ComposerPrimitive.Root>

Once the text wraps onto a second line, the composer expands and stays expanded until the input is cleared, so the layout doesn't oscillate at the wrap boundary.

Input

Text input with keyboard shortcuts. Renders a <textarea> element unless asChild is set.

<ComposerPrimitive.Input
  submitMode="ctrlEnter"
  cancelOnEscape
  placeholder="Ask anything..."
/>

Prop

Type

Send

Submits the composer form. Renders a <button> element unless asChild is set.

<ComposerPrimitive.Send className="rounded-full bg-primary px-3 py-2 text-primary-foreground">
  Send
</ComposerPrimitive.Send>

Resume

Reconnects to an interrupted run when the runtime reports a resumable checkpoint. The button is disabled while the draft is nonempty, the thread is busy, or another resume control for the same thread is reconnecting.

Resume runs only from the button; pressing Enter in an empty composer does nothing.

<ComposerPrimitive.Resume>Resume</ComposerPrimitive.Resume>

Provide canResume and onResume through your runtime adapter.

Cancel

Cancels the current composition or edit session. Renders a <button> element unless asChild is set.

<ComposerPrimitive.Cancel className="rounded-md px-3 py-2 text-sm hover:bg-muted">
  Cancel
</ComposerPrimitive.Cancel>

AddAttachment

Opens file picker for attachments. Renders a <button> element unless asChild is set.

<ComposerPrimitive.AddAttachment multiple>
  Attach Files
</ComposerPrimitive.AddAttachment>

Prop

Type

Attachments

Renders each attachment. Prefer the children render function for new code.

<ComposerPrimitive.Attachments>
  {({ attachment }) => {
    if (attachment.type === "image") return <ImagePreview />;
    if (attachment.type === "document") return <DocumentPreview />;
    return <GenericPreview />;
  }}
</ComposerPrimitive.Attachments>

Prop

Type

AttachmentByIndex

Renders a single attachment at a specific index.

<ComposerPrimitive.AttachmentByIndex
  index={0}
  components={{ Attachment: MyAttachment }}
/>

Prop

Type

index
number
components?
ComposerAttachmentsComponentConfig

AttachmentDropzone

Drag-and-drop zone for file attachments. Sets data-dragging when a file is being dragged over it and the runtime supports attachments; without the attachments capability the dropzone accepts no files and shows no highlight. Non-file drags (text, links) are ignored. Renders a <div> element unless asChild is set.

<ComposerPrimitive.AttachmentDropzone className="rounded-xl border-2 border-dashed data-[dragging]:border-primary data-[dragging]:bg-primary/5">
  <ComposerPrimitive.Root>
    ...
  </ComposerPrimitive.Root>
</ComposerPrimitive.AttachmentDropzone>

Prop

Type

Dictate

Starts a dictation session. Renders a <button> element unless asChild is set.

<ComposerPrimitive.Dictate className="rounded-md px-3 py-2 text-sm hover:bg-muted">
  Start Dictation
</ComposerPrimitive.Dictate>

StopDictation

Stops the current dictation session. Renders a <button> element unless asChild is set.

<ComposerPrimitive.StopDictation className="rounded-md px-3 py-2 text-sm hover:bg-muted">
  Stop Dictation
</ComposerPrimitive.StopDictation>

DictationTranscript

Renders the interim transcript while dictation is active. Renders a <span> element unless asChild is set.

<ComposerPrimitive.DictationTranscript className="text-sm text-muted-foreground" />

If (deprecated)

Warning

Deprecated. Use AuiIf instead.

// Before (deprecated)
<ComposerPrimitive.If editing>...</ComposerPrimitive.If>
<ComposerPrimitive.If dictation>...</ComposerPrimitive.If>

// After
<AuiIf condition={(s) => s.composer.isEditing}>...</AuiIf>
<AuiIf condition={(s) => s.composer.dictation != null}>...</AuiIf>

Quote

Container for quoted text preview inside the composer. Only renders when a quote is set. Renders a <div> element unless asChild is set.

<ComposerPrimitive.Quote className="rounded-lg border bg-background px-3 py-2 text-sm">
  <ComposerPrimitive.QuoteText />
  <ComposerPrimitive.QuoteDismiss className="ml-2" />
</ComposerPrimitive.Quote>

QuoteText

Renders the quoted text content. Renders a <span> element unless asChild is set.

<ComposerPrimitive.QuoteText />

QuoteDismiss

Clears the active quote from the composer. Renders a <button> element unless asChild is set.

<ComposerPrimitive.QuoteDismiss className="rounded-full p-1 hover:bg-muted">
  Dismiss
</ComposerPrimitive.QuoteDismiss>

TriggerPopoverRoot

Root provider that groups one or more TriggerPopover declarations and owns the shared input plugin registry.

<ComposerPrimitive.TriggerPopoverRoot>
  <ComposerPrimitive.Root>
    <LexicalComposerInput placeholder="Type @ to mention, / for commands..." />
    <ComposerPrimitive.TriggerPopover char="@" adapter={mentionAdapter}>
      <ComposerPrimitive.TriggerPopover.Directive formatter={formatter} />
      ...
    </ComposerPrimitive.TriggerPopover>
  </ComposerPrimitive.Root>
</ComposerPrimitive.TriggerPopoverRoot>

TriggerPopover

Declares a trigger (by char, adapter) and renders the popover container. Selection behavior is declared by rendering exactly one behavior sub-primitive inside, either <TriggerPopover.Directive> (insert a directive on selection) or <TriggerPopover.Action> (run onExecute on selection). Only renders its DOM (and children) while the trigger is active in the composer input.

<ComposerPrimitive.TriggerPopover
  char="@"
  adapter={mentionAdapter}
  className="rounded-lg border bg-popover p-1 shadow-md"
>
  <ComposerPrimitive.TriggerPopover.Directive formatter={formatter} />
  <ComposerPrimitive.TriggerPopoverCategories>
    {(categories) => categories.map((category) => (
      <ComposerPrimitive.TriggerPopoverCategoryItem
        key={category.id}
        categoryId={category.id}
      >
        {category.label}
      </ComposerPrimitive.TriggerPopoverCategoryItem>
    ))}
  </ComposerPrimitive.TriggerPopoverCategories>
</ComposerPrimitive.TriggerPopover>

Behavior is supplied by exactly one of two sub-primitives:

  • <TriggerPopover.Directive formatter={...} onInserted={...} />: writes :type[label]{name=id} into the composer text (mention behavior)
  • <TriggerPopover.Action onExecute={...} removeOnExecute={...} />: runs a callback on selection; leaves a directive chip behind by default (slash-command behavior)

TriggerPopover.Directive

Behavior sub-primitive. Registers the directive-insert behavior with its parent TriggerPopover. Renders nothing.

<ComposerPrimitive.TriggerPopover.Directive
  formatter={defaultDirectiveFormatter}
  onInserted={(item) => track("mention", item.id)}
/>

TriggerPopover.Action

Behavior sub-primitive. Registers the action behavior with its parent TriggerPopover. Renders nothing. By default leaves a directive chip behind after executing (audit trail); pass removeOnExecute to strip the trigger text entirely.

<ComposerPrimitive.TriggerPopover.Action
  onExecute={(item) => commandHandlers[item.id]?.()}
  removeOnExecute={false}
/>

TriggerPopoverCategories

Render-function primitive for the top-level category list. Only renders while no category is active and search mode is off.

<ComposerPrimitive.TriggerPopoverCategories>
  {(categories) => categories.map((category) => (
    <ComposerPrimitive.TriggerPopoverCategoryItem
      key={category.id}
      categoryId={category.id}
    >
      {category.label}
    </ComposerPrimitive.TriggerPopoverCategoryItem>
  ))}
</ComposerPrimitive.TriggerPopoverCategories>

TriggerPopoverCategoryItem

Button that selects a category and drills into its items.

<ComposerPrimitive.TriggerPopoverCategoryItem categoryId="tools">
  Tools
</ComposerPrimitive.TriggerPopoverCategoryItem>

TriggerPopoverItems

Render-function primitive for the items inside the currently selected category (or global search results).

<ComposerPrimitive.TriggerPopoverItems>
  {(items) => items.map((item) => (
    <ComposerPrimitive.TriggerPopoverItem key={item.id} item={item} />
  ))}
</ComposerPrimitive.TriggerPopoverItems>

TriggerPopoverItem

Selectable item inside the popover.

<ComposerPrimitive.TriggerPopoverItem item={item}>
  {item.label}
</ComposerPrimitive.TriggerPopoverItem>

TriggerPopoverBack

Back button used when drilling from categories into a specific item list.

<ComposerPrimitive.TriggerPopoverBack className="rounded-md px-2 py-1 text-sm hover:bg-accent">
  Back
</ComposerPrimitive.TriggerPopoverBack>

Patterns

With Attachments

<ComposerPrimitive.Root>
  <ComposerPrimitive.Attachments>
    {({ attachment }) => {
      if (attachment.type === "image") return <ImagePreview />;
      if (attachment.type === "document") return <DocumentPreview />;
      return <GenericPreview />;
    }}
  </ComposerPrimitive.Attachments>
  <ComposerPrimitive.Input placeholder="Ask anything..." />
  <ComposerPrimitive.AddAttachment>
    Attach
  </ComposerPrimitive.AddAttachment>
  <ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
</ComposerPrimitive.Root>

With Drag-and-Drop

Wrap your composer with AttachmentDropzone to support dragging files directly onto the input area:

<ComposerPrimitive.AttachmentDropzone className="rounded-xl border-2 border-dashed data-[dragging]:border-primary data-[dragging]:bg-primary/5">
  <ComposerPrimitive.Root>
    <ComposerPrimitive.Attachments>
      {() => <MyAttachment />}
    </ComposerPrimitive.Attachments>
    <ComposerPrimitive.Input placeholder="Drop files or type..." />
    <ComposerPrimitive.AddAttachment>Attach</ComposerPrimitive.AddAttachment>
    <ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
  </ComposerPrimitive.Root>
</ComposerPrimitive.AttachmentDropzone>

The dropzone sets data-dragging when a file is being dragged over it, so you can style the active state with CSS. It requires the runtime's attachments capability (an attachment adapter); without it the dropzone stays inert.

With Voice Input

<ComposerPrimitive.Root>
  <ComposerPrimitive.Input placeholder="Ask or speak..." />
  <AuiIf condition={(s) => s.composer.dictation != null}>
    <ComposerPrimitive.DictationTranscript className="text-sm text-muted-foreground" />
  </AuiIf>
  <AuiIf condition={(s) => s.composer.dictation == null}>
    <ComposerPrimitive.Dictate>Mic</ComposerPrimitive.Dictate>
  </AuiIf>
  <AuiIf condition={(s) => s.composer.dictation != null}>
    <ComposerPrimitive.StopDictation>Stop</ComposerPrimitive.StopDictation>
  </AuiIf>
  <ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
</ComposerPrimitive.Root>

DictationTranscript renders a <span> showing the interim speech-to-text transcript while the user is speaking.

Info

Voice input requires a DictationAdapter configured in your runtime. See Dictation for setup.

Custom Submit Behavior

Use onSubmit on Root to intercept submission:

<ComposerPrimitive.Root
  onSubmit={(e) => {
    // Runs before the message is sent
    // e.g., track analytics, transform input
    // Call e.preventDefault() to cancel the send
  }}
>
  <ComposerPrimitive.Input />
  <ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
</ComposerPrimitive.Root>

Gate Sending on External State

Sometimes you want users to type while sending is temporarily blocked, for example while tool configuration, auth, or the first model context is still loading. Disabling the entire thread via isDisabled also blocks typing, attaching, direct composer.send() calls, and message editing.

Use isSendDisabled on the runtime adapter to keep the input usable while blocking send:

const runtime = useExternalStoreRuntime({
  isSendDisabled: !toolsLoaded,
  onNew,
  messages,
  // ...
});

When isSendDisabled is true:

  • composer.canSend becomes false
  • <ComposerPrimitive.Send> is disabled
  • Enter and the Cmd/Ctrl+Shift+Enter steer hotkey become no-ops
  • aui.composer.send() short-circuits at the runtime, so direct calls cannot escape the gate

Read the same flag from React with <AuiIf> to render hints alongside the input:

<ComposerPrimitive.Root>
  <ComposerPrimitive.Input placeholder="Ask anything..." />
  <AuiIf condition={(s) => !s.composer.canSend}>
    <p className="text-sm text-muted-foreground">Loading tools, hang on...</p>
  </AuiIf>
  <ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
</ComposerPrimitive.Root>

canSend reflects the composer's own willingness to send (editing mode, non-empty content, isSendDisabled not set). <ComposerPrimitive.Send> adds thread-level gating on top, such as an in-flight run without queue support.

isSendDisabled only gates the thread composer. Saving an in-progress message edit is unaffected, since it is a per-message action rather than a new send.

For the difference between isDisabled and isSendDisabled, see the external store adapter reference.

Ctrl+Enter to Submit

<ComposerPrimitive.Root>
  <ComposerPrimitive.Input submitMode="ctrlEnter" />
  <ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
</ComposerPrimitive.Root>

With submitMode="ctrlEnter", plain Enter inserts a newline and Ctrl/Cmd+Enter submits.

Mobile / touch devices

<ComposerPrimitive.Root>
  <ComposerPrimitive.Input unstable_insertNewlineOnTouchEnter />
  <ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
</ComposerPrimitive.Root>

unstable_insertNewlineOnTouchEnter switches Enter to insert a newline on touch-primary devices (detected via (pointer: coarse) and (not (any-pointer: fine))), so the on-screen Return key never accidentally sends a half-finished message. Submission moves to the explicit Send button, matching WhatsApp, Slack, Discord, iMessage, ChatGPT, and Claude.ai.

Desktop behavior is unchanged. A tablet paired with a hardware keyboard still gets submitMode's usual semantics if you set submitMode="ctrlEnter" or "none", because the flag only downgrades the default "enter" mode.

Floating Composer

Primitives aren't bound to any layout. A floating composer uses the same ComposerPrimitive.Root and ComposerPrimitive.Input, positioned with CSS:

import { ComposerPrimitive } from "@assistant-ui/react";

function FloatingComposer() {
  return (
    <div className="fixed bottom-6 left-1/2 z-40 w-full max-w-md -translate-x-1/2">
      <ComposerPrimitive.Root>
        <div className="rounded-xl border bg-background/80 shadow-lg backdrop-blur-sm">
          <ComposerPrimitive.Input
            asChild
            unstable_focusOnRunStart={false}
            unstable_focusOnScrollToBottom={false}
          >
            <textarea
              placeholder="Ask a question..."
              className="w-full resize-none bg-transparent px-3 py-2.5 text-sm focus:outline-none"
              rows={1}
            />
          </ComposerPrimitive.Input>
        </div>
      </ComposerPrimitive.Root>
    </div>
  );
}

The primitive handles composing the message. Layout, animation, and visibility logic stay in your code.

Relationship to Components

The Thread component includes a full composer built from these primitives. Start there for a working chat UI. When you need a composer that doesn't fit inside a thread, such as a floating input, a sidebar composer, or a multi-step form, reach for ComposerPrimitive directly.

API Reference

For full prop details on every part, see the ComposerPrimitive API Reference.

Related: