Composer

Rich-text chat input with chips, slash/mention commands, attachments, and an ask-user flow.

Usage guidelines

  • Chat input — a hand-rolled contenteditable over a flat segment model: native typing and IME, inline chips, attachments.
  • Prefix commands — type /, @, or other prefixes to open command lists.
  • Panel — hosts command results, live steps, or an ask-user prompt above the field.
  • Headless — behavior lives in @intentface/chat; the demos below show one way to style it, and every class in them is yours to change.
  • Isolate it from your stream — see Why the composer needs isolating. The editor is the most expensive thing on the page to re-render per token.
  • Get started — see Quick start to add the package.

Anatomy

The bare nesting — every part is optional except Composer and Container:

<Composer.Root onSubmit={handleSubmit}>
  <Composer.Panel>
    {(composer) =>
      composer.commands.active && (
        <Composer.Command prefix="@">
          <Composer.CommandLoading />
          <Composer.CommandEmpty />
          <Composer.CommandList>
            {(item) => (
              <Composer.CommandItem value={item.value}>
                <Composer.CommandItemLabel>{item.label}</Composer.CommandItemLabel>
              </Composer.CommandItem>
            )}
          </Composer.CommandList>
        </Composer.Command>
      )
    }
  </Composer.Panel>
  <Composer.Container>
    <Composer.Textarea>
      <Composer.Placeholder />
    </Composer.Textarea>
    <Composer.Actions>
      <Composer.Submit />
    </Composer.Actions>
  </Composer.Container>
</Composer.Root>

Composer.Panel takes plain children or a callback receiving the composer state, and shows only while its resolved content is non-empty. Gate each part on the state it belongs to (commands.active, askUser.active, …) and the panel opens and closes to match — priority is the order of your branches.

Examples

Mention command list

Type @ to open the command list — the panel routes to it automatically while a prefix is active. commands maps each prefix to its config and items.

Floating command popover

Composer.Popover is the floating alternative to Composer.Panel. It takes the same children — plain nodes or a state callback — but portals them above the field, anchored to the active trigger token, so the list overlays instead of growing the composer and needs no reserved height. It's collision-aware — near a viewport edge it flips, shifts, and caps its height to stay on screen. Mount one or the other; the content is identical.

Ask-user flow

Setting the questions prop — typically from an assistant's clarifying question — arms the ask-user flow and flips askUser.active; compose the AskUser parts from @intentface/chat/ask-user inside a Panel (or Popover), reading the current step from useComposer(c => c.askUser). The flow steps through each question (single- or multi-select), and answering or skipping the last one fires onSubmit with { kind: "answers" }. Passing a fresh questions array re-arms it from the first step.

Attachments

Composer.Attachments carries the accept/limit policy and the hidden file input — it renders no strip of its own. The visible list is yours: read useComposer(c => c.attachments) and lay the items out with the @intentface/chat/attachments parts. Composer.AttachmentTrigger opens the file dialog, and files can also be dropped onto the composer.

Controlled value

Composer.Textarea accepts a controlled plain-text value with onValueChange. Here the parent's buttons drive the field and typing reports back.

Reaching a composer from outside

Every Composer.Root creates its own isolated store, so several composers can share a page with no wiring at all. Reaching into one from outside its tree is the case that needs a handle: Composer.createStore() returns one you own, and passing it as store lets a toolbar, a shortcut or a status bar drive the composer through store.controller or useComposerStore, with no context or ref threading.

Why the composer needs isolating

Typing costs nothing outside the composer: editor state lives in the store and parts subscribe by slice with useComposer(selector), so a keystroke re-renders only the parts that read the changed slice — never your app tree.

The integration risk runs the other way: a streaming chat re-rendering the composer on every chunk. The editor is the most expensive thing to re-render per token, so isolate it behind a thin bridge — subscribe to your messages state in a small component, derive the panel props there, and hand them to a memoized inner composer:

// Reads the stream and derives only what the composer needs.
const ChatInput = ({ messages, status }: ChatInputProps) => {
  const panel = derivePanelState(messages, status);
  return <ChatInputInner panel={panel} status={status} />;
};

const ChatInputInner = memo(({ panel, status }: ChatInputInnerProps) => (
  <Composer.Root /* … the composer tree … */ />
));

derivePanelState is yours — what belongs in the panel (an ask-user prompt, a status line) is your product's policy. Return a referentially stable value while nothing has changed, so the inner composer bails on every chunk except real panel transitions.

Keyboard

Composer.Root is a <form>, so submission and key handling follow form semantics. The editor interprets keys by mode — a command list being open, or ask-user being active, takes priority over normal typing.

Normal typing

KeyDescription
EnterSubmit the form (requestSubmit).
Shift + EnterInsert a soft line break.
BackspaceWhen the editor is empty and attachments exist, remove the last attachment.

Command list open

KeyDescription
ArrowUp / ArrowDownMove the highlight through the items.
ArrowLeft / ArrowRightMove the caret within the trigger token.
Enter / TabSelect the highlighted item.
EscapeClose the command list.

Ask-user active

KeyDescription
ArrowUp / ArrowDownNavigate options; up past the first refocuses the editor.
ArrowLeft / ArrowRightGo to the previous / next question.
EnterSelect the highlighted option.
EscapeDismiss the current step.
Any characterFocus the editor and start typing a free-text answer.

While isSubmitting, Submit (via useComposerSubmit) also listens document-wide for Escape to call onStop, unless the event was already handled.

API reference

Every part accepts className, style, and render (see Styling) and emits a bespoke part attribute (data-<part>) unless noted. Only part-specific props and state-driven attributes are listed below. These tables are hand-authored from the package source.

Composer

The <form> that owns submission, store resolution, drag-and-drop, and the prop → store bridges. Renders data-composer-root.

PropTypeDefault
onSubmit(data: ComposerSubmitData) => void | Promise<void>
commandsComposerCommandsMap
{}
questionsAskUserQuestion[]
isSubmittingboolean
false
valueComposerSnapshot
defaultValueComposerSnapshot
onValueChange(snapshot: ComposerSnapshot) => void
storeComposerStore
per-mount instance
AttributeValuesDetails
data-composer-root
data-submitting
data-dragging

Composer.createStore()

Returns a ComposerStore handle. Pass it to store, read it with useComposerStore(store, selector), and drive it imperatively through store.controller (focus, blur, clear, insertText, insertChip, getText, setText, serialize, ensureFocus).

onSubmit receives a discriminated ComposerSubmitData:

type ComposerSubmitData =
  | { kind: "message"; text: string; files: AttachmentItem[] }
  | { kind: "answers"; answers: ComposerAnswerEntry[] };

files are the generic attachment descriptors — your onSubmit adapts them to your wire format (this app inlines blob URLs into AI SDK file parts with its prepareAttachmentsForSend helper).

Composer.Container

Focus proxy and layout frame. Renders data-composer-container; clicking its chrome focuses the editor. Not focusable itself — it carries no role or tab stop.

Composer.Textarea

The editor surface — a contenteditable that acts like a native textarea with atomic mention chips. Renders data-composer-textarea wrapping the editable element (data-composer-editor). With name set, a hidden input mirrors the serialized text into the surrounding form's FormData.

PropTypeDefault
valuestring
onValueChange(text: string) => void
disabledboolean
false
autoFocusboolean
false
placeholderstring
submitOn"enter" | "shift-enter"
"enter"
renderChip(chip: ChipData) => ReactNode
childrenReactNode
maxLengthnumber
requiredboolean
false
namestring
spellCheckboolean
false
onFocus / onBlur / onKeyDown / onKeyUp / onPaste / onCopy / onCutReact handlers
AttributeValuesDetails
data-composer-textarea
data-composer-editor
data-filled
data-disabled
data-command-badge
data-command-placeholder
data-command-hint

Composer.Placeholder

Static or custom placeholder. Renders data-composer-placeholder-text. Pass either placeholder or children, not both.

PropTypeDefault
placeholderstring
childrenReactNode

Composer.ContextWindow

A slot above the input, open only when it has content and no panel is active. Content-driven and exposing data-open/data-closed like Panel/Popover. Renders data-composer-context-window.

AttributeValuesDetails
data-composer-context-window
data-open
data-closed

Composer.Actions

Layout row for buttons. Renders data-composer-actions. No part-specific props or state attributes.

Composer.Submit

Submit button that morphs into a stop control while generating. Renders data-composer-submit.

PropTypeDefault
isGeneratingboolean
false
onStop() => void
AttributeValuesDetails
data-composer-submit
data-generating

Composer.Attachments

Owns the hidden file input and renders the file strip / drop zone as children. This part does not take className / style / render and emits no part attribute of its own.

PropTypeDefault
convert(file: File) => AttachmentItem
blob ingestion
destroy(item: AttachmentItem) => void
revoke blob URL
acceptstring
"" (everything)
maxFilesnumber
unlimited
maxFileSizenumber
unlimited
multipleboolean
true
globalDropboolean
false
childrenReactNode

Composer.AttachmentTrigger

Button that opens the file dialog. Renders data-composer-attachment-trigger named "Add attachment" by default. No part-specific props.

Composer.Panel

A surface region above the field. children is either plain nodes or a callback (composer) => ReactNode receiving the composer state, so you pick what to show by priority (commands.active ? <Command/> : askUser.active ? <AskUser.Root/> : null). By default (anchor) it renders as a collision-aware, portaled overlay anchored to the Container — flipping / shifting / sizing to stay on screen (via @floating-ui/dom); anchor a ref/element elsewhere, or pass anchor={false} for an in-flow block that grows the composer. Pass pin to hold the placement without flip/shift. open — whether the resolved content is non-empty — arrives as the render prop's second argument and is mirrored to data-open/data-closed; the host stays mounted through its close animation (exposing data-starting-style/data-ending-style) and, when positioned, publishes the resolved data-side/data-align so the transition origin follows a flip.

When positioned, the overlay publishes the anchor's geometry as CSS variables — opt in from your styling rather than having the primitive impose a size (Base UI-style): --anchor-width / --anchor-height (the anchor's box, e.g. width: var(--anchor-width) to match the composer) and --anchor-available-height (free space toward the resolved side, e.g. max-height: var(--anchor-available-height) so the content scrolls instead of overflowing).

PropTypeDefault
childrenReactNode | ((composer: ComposerState) => ReactNode)
anchorboolean | Element | RefObject<Element>
side"top" | "bottom" | "left" | "right"
align"start" | "center" | "end"
sideOffsetnumber
pinboolean
AttributeValuesDetails
data-composer-panel
data-open
data-closed
data-starting-style
data-ending-style
data-side"top" | "bottom" | "left" | "right"
data-align"start" | "center" | "end"

Composer.Popover

Floating alternative to Composer.Panel. Takes the same children (nodes or a state callback) but portals them to the body, anchored to the active trigger token — overlaying instead of growing the composer. It's collision-aware (via @floating-ui/dom): opens upward by default and flips below / shifts / caps its height to stay on screen, tracking the anchor across scroll, resize, and composer growth. It stays mounted and exposes open the same way as Panel (the render prop's second argument plus data-open/data-closed), and publishes the resolved data-side/data-align so the transition origin follows a flip. Positioning is written imperatively — the styled layer supplies only box and animation styling, not left/top.

PropTypeDefault
childrenReactNode | ((composer: ComposerState) => ReactNode)
pinboolean
AttributeValuesDetails
data-composer-popover
data-open
data-closed
data-side"top" | "bottom" | "left" | "right"
data-align"start" | "center" | "end"

Composer.Command

The command popup for one prefix. Renders data-composer-command-list only while that prefix is active (returns nothing otherwise).

PropTypeDefault
prefixstring
(required)
AttributeValuesDetails
data-composer-command-list
data-loading
data-empty

Composer.CommandList

Maps resolved items through a render-prop child. Renders data-composer-command-items.

PropTypeDefault
children(item: Item) => ReactNode
(required)

Composer.CommandItem

One selectable row. Renders data-composer-command-item. To disable a row, set disabled: true on its item data (not on this component) — the row renders inert (aria-disabled + data-disabled), the keyboard highlight skips it, and mouse selection is a no-op. Disabled rows still match the filter.

PropTypeDefault
valuestring
(required)
AttributeValuesDetails
data-composer-command-item
data-highlighted
data-disabled

Row content & states

Composer.CommandItemIcon, Composer.CommandItemLabel, and Composer.CommandItemDescription render <span>s with data-composer-command-item-{icon,label,description}. Composer.CommandLoading (composer-command-loading), Composer.CommandEmpty (composer-command-empty), and Composer.CommandDismiss (composer-command-dismiss, a button) fill the list states — all render only your children, so you supply the copy. To group, give Composer.CommandGroup a groupBy and a render callback: it buckets the resolved (already-filtered) items by your key — in first-appearance order, so keyboard nav still flows top-to-bottom — and calls the callback once per group with (group, items), wrapping each in data-command-group. You render Composer.CommandGroupLabel (composer-command-group-label) + the group's items:

<Composer.CommandGroup groupBy={(item: Issue) => item.group}>
  {(group, items) => (
    <>
      <Composer.CommandGroupLabel>{group}</Composer.CommandGroupLabel>
      {items.map((item) => (
        <Composer.CommandItem key={item.value} value={item.value}>
          <Composer.CommandItemLabel>{item.label}</Composer.CommandItemLabel>
        </Composer.CommandItem>
      ))}
    </>
  )}
</Composer.CommandGroup>

Ask-user

The composer owns the ask-user statequestions, the current step, the answers — but renders none of the question UI. Compose that from the AskUser namespace in @intentface/chat/ask-user, reading the step through useComposer((c) => c.askUser) and gating the enclosing Panel or Popover on askUser.active.

AskUser.Dismiss is a plain button you wire to askUser.dismissStep; AskUser.Continue is type="submit", so the enclosing Composer.Root form drives it. Neither ships copy — supply the labels as children, and read askUser.isLastStep to switch the continue wording.

Hooks

PropTypeDefault
useComposer(selector?) => Selected
useComposerStore(store, selector?) => Selected
useComposerController() => ComposerEditorState
useComposerSubmit(options) => ComposerSubmitState
useCommandListItems() => { items, state }