--- title: Composer description: Rich-text chat input with chips, slash/mention commands, attachments, and an ask-user flow. source: composer --- ## 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 + styled** — behavior lives in `@intentface/chat`; the styled wrapper below is yours to copy and edit. - **Get started** — see [Installation](/docs/installation) to add the package and copy the component. ## Anatomy The bare nesting — every part is optional except `Composer` and `Container`: ```tsx {(composer) => composer.commands.active && ( {(item) => ( {item.label} )} ) } ``` `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`; render `` inside a `Panel` (or `Popover`) gated on that flag. 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` renders the file strip and drop zone above the input; `Composer.AttachmentTrigger` opens the file dialog. 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 `` 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). See [Composer state](/docs/headless/state) for the full model. ## 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: ```tsx const ChatInput = () => { const { messages, status } = useChatMessages(); const panelState = useAskUserPanelState(messages, status); return ; }; const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => ( )); ``` The derivation (`useAskUserPanelState` here) is app code — what to surface in the panel (an ask-user prompt, a status line, …) is your product's policy, so you own it. Return a referentially stable value while nothing transitioned, so the inner composer bails on every chunk except real panel changes. See [Streaming performance](/docs/headless/performance) for the full render model. ## Keyboard `Composer.Root` is a `
`, 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** export const normalKeys = [ { attribute: "Enter", description: "Submit the form (requestSubmit)." }, { attribute: "Shift + Enter", description: "Insert a soft line break." }, { attribute: "Backspace", description: "When the editor is empty and attachments exist, remove the last attachment." }, ]; **Command list open** export const commandKeys = [ { attribute: "ArrowUp / ArrowDown", description: "Move the highlight through the items." }, { attribute: "ArrowLeft / ArrowRight", description: "Move the caret within the trigger token." }, { attribute: "Enter / Tab", description: "Select the highlighted item." }, { attribute: "Escape", description: "Close the command list." }, ]; **Ask-user active** export const askUserKeys = [ { attribute: "ArrowUp / ArrowDown", description: "Navigate options; up past the first refocuses the editor." }, { attribute: "ArrowLeft / ArrowRight", description: "Go to the previous / next question." }, { attribute: "Enter", description: "Select the highlighted option." }, { attribute: "Escape", description: "Dismiss the current step." }, { attribute: "Any character", description: "Focus 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 ``; `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 [PrimitiveProps](/docs/headless/types)) and emits a bespoke part attribute (`data-`) unless noted. Only part-specific props and state-driven attributes are listed below. These tables are hand-authored from the package source. ### Composer The `` that owns submission, store resolution, drag-and-drop, and the prop → store bridges. Renders `data-composer-root`. export const rootProps = [ { name: "onSubmit", type: "(data: ComposerSubmitData) => void | Promise", description: "Fires on message submit and on ask-user answers. Discriminate on data.kind." }, { name: "commands", type: "ComposerCommandsMap", default: "{}", description: "Prefix → command config (kind, trigger, items, suggestion, placeholder). suggestion: false disables ghost-text completion; placeholder sets the per-prefix empty-query hint (never shown alongside a suggestion — the suggestion wins)." }, { name: "questions", type: "AskUserQuestion[]", description: "When set, arms the ask-user flow (blurs the editor, flips askUser.active)." }, { name: "isSubmitting", type: "boolean", default: "false", description: "Flips Submit to a stop affordance and gates re-submits." }, { name: "value", type: "ComposerSnapshot", description: "Controlled editor content." }, { name: "defaultValue", type: "ComposerSnapshot", description: "Uncontrolled initial editor content." }, { name: "onValueChange", type: "(snapshot: ComposerSnapshot) => void", description: "Fires on editor updates with a fresh snapshot." }, { name: "store", type: "ComposerStore", default: "per-mount instance", description: "An explicit Composer.createStore() handle. A per-mount instance is reset on unmount; an explicit handle is not." }, ]; export const rootAttrs = [ { attribute: "data-composer-root", description: "The form element." }, { attribute: "data-submitting", description: "Present while isSubmitting is true." }, { attribute: "data-dragging", description: "Present 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`: ```ts 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. export const textareaProps = [ { name: "value", type: "string", description: "Controlled plain-text value." }, { name: "onValueChange", type: "(text: string) => void", description: "Fires on each editor update with plain text." }, { name: "disabled", type: "boolean", default: "false", description: "Makes the editor non-editable." }, { name: "autoFocus", type: "boolean", default: "false", description: "Focus the editor on mount." }, { name: "placeholder", type: "string", description: "Native-textarea-style placeholder (also sets aria-placeholder). For rich content, pass children instead." }, { name: "submitOn", type: '"enter" | "shift-enter"', default: '"enter"', description: "Which Enter chord sends the message; the other inserts a soft break. Pair \"shift-enter\" with enterKeyHint=\"enter\"." }, { name: "renderChip", type: "(chip: ChipData) => ReactNode", description: "Custom renderer for a committed chip. ChipData carries prefix, value, label, and icon — the default renders label only; add chip.prefix in front to show the trigger." }, { name: "children", type: "ReactNode", description: "Placeholder overlay, shown while empty." }, { name: "maxLength", type: "number", description: "Logical-length cap — one per character, one per chip. IME input is capped at composition commit." }, { name: "required", type: "boolean", default: "false", description: "Native form validation via the hidden input (requires name)." }, { name: "name", type: "string", description: "Mirrors the serialized text (chips as chip-markdown) into FormData." }, { name: "spellCheck", type: "boolean", default: "false", description: "Forwarded to the editable element, with autoCapitalize, enterKeyHint, inputMode, dir, and aria-* passthrough." }, { name: "onFocus / onBlur / onKeyDown / onKeyUp / onPaste / onCopy / onCut", type: "React handlers", description: "Route to the editable element, native-textarea style: they run before the engine, and preventDefault overrides the engine's handling (keydown/clipboard)." }, ]; export const textareaAttrs = [ { attribute: "data-composer-textarea", description: "The wrapper element." }, { attribute: "data-composer-editor", description: "The contenteditable editor element." }, { attribute: "data-filled", description: "Present when the editor has content." }, { attribute: "data-disabled", description: "Present when disabled." }, { attribute: "data-command-badge", description: "On the active trigger token decoration (e.g. the leading @)." }, { attribute: "data-command-placeholder", description: "On the inline hint decoration after a trigger." }, { attribute: "data-command-hint", description: "The 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. export const placeholderProps = [ { name: "placeholder", type: "string", description: "Static placeholder text (mutually exclusive with children)." }, { name: "children", type: "ReactNode", description: "Custom placeholder content (mutually exclusive with placeholder)." }, ]; ### 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`. export const contextWindowAttrs = [ { attribute: "data-composer-context-window", description: "The context-window element." }, { attribute: "data-open", description: "Present while the window has content and no panel is active." }, { attribute: "data-closed", description: "Present 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`. export const submitProps = [ { name: "isGenerating", type: "boolean", default: "false", description: "Morphs into a stop control: type becomes \"button\" and clicking calls onStop." }, { name: "onStop", type: "() => void", description: "Abort callback — on click while generating, or Escape." }, ]; export const submitAttrs = [ { attribute: "data-composer-submit", description: "The button element." }, { attribute: "data-generating", description: "Present 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. export const attachmentsProps = [ { name: "convert", type: "(file: File) => AttachmentItem", default: "blob ingestion", description: "Converts a picked/dropped/pasted File into an item. Override (with destroy) for custom ids, extra fields, or upload-backed items." }, { name: "destroy", type: "(item: AttachmentItem) => void", default: "revoke blob URL", description: "Cleanup for a removed item." }, { name: "accept", type: "string", default: '"" (everything)', description: "Accepted MIME types — the package imposes no policy; pass yours." }, { name: "maxFiles", type: "number", default: "unlimited", description: "Maximum file count." }, { name: "maxFileSize", type: "number", default: "unlimited", description: "Maximum size per file." }, { name: "multiple", type: "boolean", default: "true", description: "Allow selecting multiple files." }, { name: "globalDrop", type: "boolean", default: "false", description: "Accept drops anywhere in the document, not just over the composer." }, { name: "children", type: "ReactNode", description: "The visible strip / drop zone." }, ]; ### 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 ? : askUser.active ? : 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). export const panelProps = [ { name: "children", type: "ReactNode | ((composer: ComposerState) => ReactNode)", description: "Panel content, or a callback that reads composer state and returns one branch by priority." }, { name: "anchor", type: "boolean | Element | RefObject", description: "Positioned, portaled overlay (default true, anchored to the Container) or an in-flow block (false). A ref/element anchors elsewhere. Match its width with width: var(--anchor-width)." }, { name: "side", type: '"top" | "bottom" | "left" | "right"', description: "Overlay preferred side; flips to the opposite on collision. Default \"top\"." }, { name: "align", type: '"start" | "center" | "end"', description: "Overlay alignment along the side. Default \"center\"." }, { name: "sideOffset", type: "number", description: "Overlay gap between the anchor and the panel, in px. Default 0." }, { name: "pin", type: "boolean", description: "Hold side/align without collision repositioning (drops flip + shift). Default false." }, ]; export const panelAttrs = [ { attribute: "data-composer-panel", description: "The panel element." }, { attribute: "data-open", description: "Present while the resolved content is non-empty." }, { attribute: "data-closed", description: "Present while empty." }, { attribute: "data-starting-style", description: "Present on the first open frame — the enter transition's from-state." }, { attribute: "data-ending-style", description: "Present while the close transition runs, before unmount." }, { attribute: "data-side", values: '"top" | "bottom" | "left" | "right"', description: "Resolved side when positioned (anchor) — the origin to animate from." }, { attribute: "data-align", values: '"start" | "center" | "end"', description: "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`. export const popoverProps = [ { name: "children", type: "ReactNode | ((composer: ComposerState) => ReactNode)", description: "Same content as Panel — nodes or a state callback." }, { name: "pin", type: "boolean", description: "Hold the placement without collision repositioning (drops flip + shift). Default false." }, ]; export const popoverAttrs = [ { attribute: "data-composer-popover", description: "The portaled popover element." }, { attribute: "data-open", description: "Present while the resolved content is non-empty." }, { attribute: "data-closed", description: "Present while empty (stays mounted, holding its last position)." }, { attribute: "data-side", values: '"top" | "bottom" | "left" | "right"', description: "Resolved side — flips to \"bottom\" when there's no room above; the origin to animate from." }, { attribute: "data-align", values: '"start" | "center" | "end"', description: "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). export const commandListProps = [ { name: "prefix", type: "string", default: "(required)", description: "Which trigger prefix this list serves." }, ]; export const commandListAttrs = [ { attribute: "data-composer-command-list", description: "The list element." }, { attribute: "data-loading", description: "Present while async items are being fetched." }, { attribute: "data-empty", description: "Present when no items match." }, ]; ### Composer.CommandList Maps resolved items through a render-prop child. Renders `data-composer-command-items`. export const commandItemsProps = [ { name: "children", type: "(item: Item) => ReactNode", default: "(required)", description: "Row renderer, called per resolved item." }, ]; ### 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. export const commandItemProps = [ { name: "value", type: "string", default: "(required)", description: "Item identity, matched against the highlight and selection." }, ]; export const commandItemAttrs = [ { attribute: "data-composer-command-item", description: "The row button." }, { attribute: "data-highlighted", description: "Present when this row is the active highlight." }, { attribute: "data-disabled", description: "Present when the item data marks this row disabled." }, ]; ### Row content & states `Composer.CommandItemIcon`, `Composer.CommandItemLabel`, and `Composer.CommandItemDescription` render ``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: ```tsx item.group}> {(group, items) => ( <> {group} {items.map((item) => ( {item.label} ))} )} ``` ### Composer.AskUser Renders the active question. Put it inside a `Panel` or `Popover`, gated on `askUser.active`. No props — it reads the store's ask-user state and renders nothing when there's no question. ### Ask-user actions `Composer.AskUserHints` is the structural slot for keyboard hints. `Composer.AskUserDismiss` is a button wired to dismiss the current step (`data-composer-ask-user-dismiss`). `Composer.AskUserContinue` is a submit button (`data-composer-ask-user-continue`). None ship copy — supply the labels as children; read `useComposer((c) => c.askUser.isLastStep)` to switch continue/submit wording. ### Hooks export const hooks = [ { name: "useComposer", type: "(selector?) => Selected", description: "Subscribe to a slice of the nearest composer's store. Throws outside ." }, { name: "useComposerStore", type: "(store, selector?) => Selected", description: "Same, for an explicit createStore() handle — the outside-the-tree twin." }, { name: "useComposerController", type: "() => ComposerEditorState", description: "The nearest composer's imperative editor controls (focus, insert, clear, …)." }, { name: "useComposerSubmit", type: "(options) => ComposerSubmitState", description: "Computes Submit's type/disabled/generating; auto-disables while empty or submitting; aborts on Escape." }, { name: "useCommandListItems", type: "() => { items, state }", description: "The resolved items and load state inside a Command. Throws outside one." }, ];