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.
  • 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 just 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.

External store

Composer.createStore() returns a handle you own. Pass it via store and drive the composer from anywhere — a toolbar, a shortcut — through store.controller, with no context or ref threading.

Multiple instances

Every <Composer.Root> creates its own isolated store, so several composers can live on one page with no wiring. To reach a composer from outside its tree — toolbars, keyboard shortcuts, status bars — create an explicit handle with Composer.createStore(), pass it via the store prop, and use store.controller (imperative) or useComposerStore(store, selector) (reactive).

Performance

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

AttributeDescription
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

AttributeDescription
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

AttributeDescription
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.

Accessibility

Command popup (combobox)

The editor is a role="textbox" with combobox wiring: aria-autocomplete="list", aria-haspopup="listbox", plus aria-controls and aria-activedescendant while a trigger popup is open. The popup renders role="listbox" (labelled "Suggestions", overridable) with role="option" rows carrying stable ids and aria-selected on the highlight — keyboard selection stays in the editor, so rows are never tab stops. Grouped lists wrap in role="group" labelled by their CommandGroupLabel. Async resolution sets aria-busy on the listbox and the empty state is a role="status" region. Committed mention chips announce as atomic tokens ("Label, @ mention").

One deliberate deviation from the strict APG combobox pattern: aria-expanded is omitted — ARIA 1.2 forbids it on textbox, and switching to role="combobox" would forbid aria-multiline, which matters more for a multiline chat field. The popup announces through aria-haspopup and live aria-activedescendant narration instead. Tab selects the highlighted option while the popup is open (Linear-style) rather than moving focus.

The placeholder overlay is aria-hidden — assistive tech hears the string placeholder prop via aria-placeholder. When rich children replace the string, keep a placeholder string alongside (children win visually) so the hint still announces.

Ask-user questions

While questions are active, the options form a labelled group: AskUser.Options renders role="radiogroup" (single-select) or role="group" (multi-select), named by AskUser.Label and described by AskUser.StepLabel automatically. Each AskUser.Option is the real control — role="radio" / role="checkbox" with aria-checked — and holds the group's single tab stop via roving tabindex: DOM focus follows the highlight, entering the group on arrival and moving with ArrowUp/ArrowDown. Enter and Space select; Escape dismisses the step (from the options or the editor); typing any character returns focus to the editor as a free-text answer. Never nest an interactive control inside an option — the option itself is the control, and the composer's question-mode key handling treats native inputs as foreign editables.

  • Form semantics. The root renders a <form>; Enter submits and Submit is a real submit button, so the composer works with standard form and assistive-tech expectations.
  • Click-to-focus. Container is a mouse-only focus passthrough: clicking its chrome focuses the editor, but clicks on nested buttons, links, and inputs pass through. It carries no role and no tab stop — keyboard users tab straight to the editor.
  • Ask-user focus. Activating questions moves focus into the options group; typing any character returns focus to the editor for a free-text answer.
  • Default names. Submit is named "Send message", flipping to "Stop generating" while generating so the morphed control announces correctly; AttachmentTrigger is named "Add attachment". Both are overridable via aria-label.
  • Busy states. Submitting/generating are exposed as data-submitting on the root and the Submit label flip — the package emits no live-region copy; add a consumer-owned role="status" region if you want in-flight announcements beyond the button state.

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
AttributeDescription
data-composer-rootThe form element.
data-submittingPresent while isSubmitting is true.
data-draggingPresent while files are dragged over the drop scope.

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
AttributeDescription
data-composer-textareaThe wrapper element.
data-composer-editorThe contenteditable editor element.
data-filledPresent when the editor has content.
data-disabledPresent when disabled.
data-command-badgeOn the active trigger token decoration (e.g. the leading @).
data-command-placeholderOn the inline hint decoration after a trigger.
data-command-hintThe badge's hint element — ghost-text completion of the highlighted item, or the empty-query placeholder. A real span, engine-owned like the badge.

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.

AttributeDescription
data-composer-context-windowThe context-window element.
data-openPresent while the window has content and no panel is active.
data-closedPresent while empty or yielding to an active panel.

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
AttributeDescription
data-composer-submitThe button element.
data-generatingPresent while isGenerating is true.

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
AttributeValuesDescription
data-composer-panelThe panel element.
data-openPresent while the resolved content is non-empty.
data-closedPresent while empty.
data-starting-stylePresent on the first open frame — the enter transition's from-state.
data-ending-stylePresent while the close transition runs, before unmount.
data-side"top" | "bottom" | "left" | "right"Resolved side when positioned (anchor) — the origin to animate from.
data-align"start" | "center" | "end"Resolved alignment when positioned.

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
AttributeValuesDescription
data-composer-popoverThe portaled popover element.
data-openPresent while the resolved content is non-empty.
data-closedPresent while empty (stays mounted, holding its last position).
data-side"top" | "bottom" | "left" | "right"Resolved side — flips to "bottom" when there's no room above; the origin to animate from.
data-align"start" | "center" | "end"Resolved alignment along the side.

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)
AttributeDescription
data-composer-command-listThe list element.
data-loadingPresent while async items are being fetched.
data-emptyPresent when no items match.

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)
AttributeDescription
data-composer-command-itemThe row button.
data-highlightedPresent when this row is the active highlight.
data-disabledPresent when the item data marks this row 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 }