Headless Composer Input

Build a custom composer input while keeping assistant-ui composer state and send gating.

ComposerPrimitive.Input is still the recommended composer input for most apps. It owns autosize, keyboard shortcuts, IME handling, paste-to-attachment, focus behavior, and trigger-popover keyboard integration.

Use unstable_useComposerInput when you already own the input surface, such as a custom editor, a contentEditable surface, or a textarea wrapper whose behavior cannot be expressed through ComposerPrimitive.Input's props, asChild, or render APIs.

This API is marked unstable and may change without notice. It is a thin bridge to composer text and send state, not a replacement implementation of ComposerPrimitive.Input.

Usage

Render the custom input inside a composer and mirror text changes into the assistant-ui composer state:

"use client";

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

function HeadlessComposer() {
  const composer = unstable_useComposerInput();
  const popoverAria = unstable_useTriggerPopoverAriaProps();

  return (
    <ComposerPrimitive.Root>
      <textarea
        aria-label="Message"
        value={composer.value}
        disabled={composer.isDisabled}
        onChange={(event) => {
          composer.setText(event.currentTarget.value);
        }}
        onKeyDown={(event) => {
          if (event.nativeEvent.isComposing) return;

          if (event.key === "Enter" && !event.shiftKey) {
            event.preventDefault();

            if (composer.canSend) {
              composer.send();
            }
          }
        }}
        {...popoverAria}
      />
      <ComposerPrimitive.Send />
    </ComposerPrimitive.Root>
  );
}

composer.send() exposes the same send action used by ComposerPrimitive.Send, including send options such as composer.send({ steer: true }). It is a no-op unless composer.canSend is true; check canSend in custom keyboard handlers to keep your event handling explicit.

Hook Result

FieldDescription
valueCurrent composer text. Returns "" when the composer is not editing.
setText(text)Writes text into the composer while it is editing.
send(options?)Sends the current composer message when canSend is true; otherwise a no-op.
isDisabledCombines the hook's disabled option with assistant-ui disabled sources, such as thread disabled state and active dictation input lock.
canSendMatches ComposerPrimitive.Send gating, then also respects isDisabled.

Pass disabled when your editor has an additional read-only state:

const composer = unstable_useComposerInput({
  disabled: editorReadOnly,
});

What You Still Own

The hook does not recreate the behavior of ComposerPrimitive.Input. Your input or editor remains responsible for:

  • Enter/newline shortcuts, steer shortcuts, escape/cancel behavior, and any other keyboard behavior.
  • IME and composition handling.
  • Cursor and selection tracking.
  • Autosize and focus management.
  • Paste/drop attachment behavior.
  • Rich text state, serialization, and DOM synchronization for contentEditable or editor-library integrations.

For styled textareas, prefer ComposerPrimitive.Input. Reach for the headless hook when the editor has its own model or DOM lifecycle and assistant-ui should only supply composer state and send gating.

Trigger Popovers

unstable_useTriggerPopoverAriaProps returns the combobox ARIA attributes for the currently open trigger popover:

const popoverAria = unstable_useTriggerPopoverAriaProps();

<textarea {...popoverAria} />;

Spread these props last so they can mirror ComposerPrimitive.Input when a popover is open.

This helper only describes the open popover to assistive technology. It does not wire a custom editor into mention or slash-command keyboard handling, cursor tracking, or item insertion. For the full built-in trigger-popover experience, use ComposerPrimitive.Input; for richer editors, integrate the trigger UI with the editor's own selection and keyboard model.