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
| Attribute | Description |
|---|---|
Enter | Submit the form (requestSubmit). |
Shift + Enter | Insert a soft line break. |
Backspace | When the editor is empty and attachments exist, remove the last attachment. |
Command list open
| Attribute | Description |
|---|---|
ArrowUp / ArrowDown | Move the highlight through the items. |
ArrowLeft / ArrowRight | Move the caret within the trigger token. |
Enter / Tab | Select the highlighted item. |
Escape | Close the command list. |
Ask-user active
| Attribute | Description |
|---|---|
ArrowUp / ArrowDown | Navigate options; up past the first refocuses the editor. |
ArrowLeft / ArrowRight | Go to the previous / next question. |
Enter | Select the highlighted option. |
Escape | Dismiss the current step. |
Any character | 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
<form>;Entersubmits andSubmitis a real submit button, so the composer works with standard form and assistive-tech expectations. - Click-to-focus.
Containeris 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
questionsmoves focus into the options group; typing any character returns focus to the editor for a free-text answer. - Default names.
Submitis named "Send message", flipping to "Stop generating" while generating so the morphed control announces correctly;AttachmentTriggeris named "Add attachment". Both are overridable viaaria-label. - Busy states. Submitting/generating are exposed as
data-submittingon the root and theSubmitlabel flip — the package emits no live-region copy; add a consumer-ownedrole="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.
| Prop | Type | Default |
|---|---|---|
onSubmit | (data: ComposerSubmitData) => void | Promise<void> | — |
commands | ComposerCommandsMap | {} |
questions | AskUserQuestion[] | — |
isSubmitting | boolean | false |
value | ComposerSnapshot | — |
defaultValue | ComposerSnapshot | — |
onValueChange | (snapshot: ComposerSnapshot) => void | — |
store | ComposerStore | per-mount instance |
| Attribute | Description |
|---|---|
data-composer-root | The form element. |
data-submitting | Present while isSubmitting is true. |
data-dragging | 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:
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.
| Prop | Type | Default |
|---|---|---|
value | string | — |
onValueChange | (text: string) => void | — |
disabled | boolean | false |
autoFocus | boolean | false |
placeholder | string | — |
submitOn | "enter" | "shift-enter" | "enter" |
renderChip | (chip: ChipData) => ReactNode | — |
children | ReactNode | — |
maxLength | number | — |
required | boolean | false |
name | string | — |
spellCheck | boolean | false |
onFocus / onBlur / onKeyDown / onKeyUp / onPaste / onCopy / onCut | React handlers | — |
| Attribute | Description |
|---|---|
data-composer-textarea | The wrapper element. |
data-composer-editor | The contenteditable editor element. |
data-filled | Present when the editor has content. |
data-disabled | Present when disabled. |
data-command-badge | On the active trigger token decoration (e.g. the leading @). |
data-command-placeholder | On the inline hint decoration after a trigger. |
data-command-hint | 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.
| Prop | Type | Default |
|---|---|---|
placeholder | string | — |
children | ReactNode | — |
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.
| Attribute | Description |
|---|---|
data-composer-context-window | The context-window element. |
data-open | Present while the window has content and no panel is active. |
data-closed | 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.
| Prop | Type | Default |
|---|---|---|
isGenerating | boolean | false |
onStop | () => void | — |
| Attribute | Description |
|---|---|
data-composer-submit | The button element. |
data-generating | 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.
| Prop | Type | Default |
|---|---|---|
convert | (file: File) => AttachmentItem | blob ingestion |
destroy | (item: AttachmentItem) => void | revoke blob URL |
accept | string | "" (everything) |
maxFiles | number | unlimited |
maxFileSize | number | unlimited |
multiple | boolean | true |
globalDrop | boolean | false |
children | ReactNode | — |
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).
| Prop | Type | Default |
|---|---|---|
children | ReactNode | ((composer: ComposerState) => ReactNode) | — |
anchor | boolean | Element | RefObject<Element> | — |
side | "top" | "bottom" | "left" | "right" | — |
align | "start" | "center" | "end" | — |
sideOffset | number | — |
pin | boolean | — |
| Attribute | Values | Description |
|---|---|---|
data-composer-panel | — | The panel element. |
data-open | — | Present while the resolved content is non-empty. |
data-closed | — | Present while empty. |
data-starting-style | — | Present on the first open frame — the enter transition's from-state. |
data-ending-style | — | Present 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.
| Prop | Type | Default |
|---|---|---|
children | ReactNode | ((composer: ComposerState) => ReactNode) | — |
pin | boolean | — |
| Attribute | Values | Description |
|---|---|---|
data-composer-popover | — | The portaled popover element. |
data-open | — | Present while the resolved content is non-empty. |
data-closed | — | Present 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).
| Prop | Type | Default |
|---|---|---|
prefix | string | (required) |
| Attribute | Description |
|---|---|
data-composer-command-list | The list element. |
data-loading | Present while async items are being fetched. |
data-empty | Present when no items match. |
Composer.CommandList
Maps resolved items through a render-prop child. Renders
data-composer-command-items.
| Prop | Type | Default |
|---|---|---|
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.
| Prop | Type | Default |
|---|---|---|
value | string | (required) |
| Attribute | Description |
|---|---|
data-composer-command-item | The row button. |
data-highlighted | Present when this row is the active highlight. |
data-disabled | Present 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 state — questions, 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
| Prop | Type | Default |
|---|---|---|
useComposer | (selector?) => Selected | — |
useComposerStore | (store, selector?) => Selected | — |
useComposerController | () => ComposerEditorState | — |
useComposerSubmit | (options) => ComposerSubmitState | — |
useCommandListItems | () => { items, state } | — |