10.7k

Composer

A composer for chat and AI assistant interfaces, with a growing input, persistent actions, and optional mentions or slash commands.

Installation

Choose an input

ComposerInput is the input surface in either case. By default it renders a contenteditable div; give it an accessible name:

For a plain-text message, render a native textarea in the same ComposerInput:

You can also use a render callback or a custom component that forwards its props and ref to the textarea:

Both use the same composition and draft API. Composer manages an empty draft by default. Add triggers and the popup parts for mentions or actions; these require the default contenteditable input. Use a textarea when the message should remain plain text. Put value, defaultValue, and onValueChange on Composer, rather than on the rendered textarea.

Anatomy

Composer provides the form, editing context, and visual frame. Clicking empty space inside its border focuses the input. ComposerInput defaults to contenteditable and contains a ScrollArea. Pass a textarea through render for native plain-text editing. The action row stays outside the scrolling draft.

Usage

Composer submits on Enter and inserts a line break on Shift+Enter by default. onSubmit always receives a structured draft, for either input. Use getComposerText when your application needs a string. The submit button automatically disables for empty drafts.

Omit value and onValueChange for an uncontrolled draft. Submission does not clear the message automatically. For controlled drafts, update value synchronously in onValueChange; debounce fetching or saving separately.

Submission and custom controls

Set submitDisabled on Composer while sending or whenever your application cannot accept the message. It blocks both Enter and the submit button while leaving the draft editable. Use disabled to disable the input as well, or readOnly to keep text selectable without allowing edits or submission.

allowEmpty lets you send an attachment without text. ComposerSubmit accepts a custom button through render; choose its label, icon, size, and variant. Its own disabled prop only controls that button. Use Composer.submitDisabled for a rule that must also apply to Enter.

Inside another form

Render Composer as a div to avoid nested forms. name adds the canonical message text to the containing form's FormData, for either input.

In this composition, the outer form owns saving. If you also add ComposerSubmit or enable Enter submission, those call the Composer's own onSubmit; they do not submit the outer form. Standalone Composer renders a form and runs native constraint validation before its onSubmit callback. Required-field validation is available on a rendered textarea; contenteditable does not support native required validation.

Resetting the containing form restores defaultValue (or an empty draft). Controlled owners receive onValueChange with reason: "reset" and decide whether to accept it. For custom controls within Composer, useComposerContext() exposes the draft, canSubmit, submit(), insertTrigger(), and reset().

Input size

Set row limits with CSS variables on Composer. They apply to both the default contenteditable input and a rendered textarea. By default, the input starts at one row below md and two rows from md upward. It grows to four rows below md and eight rows from md upward. Longer drafts scroll while the controls stay visible. The maximum also stays within half the viewport height. Add attachment, microphone, or other buttons inside ComposerActions.

Use responsive classes to change the limits at each breakpoint:

Here the input shows two to three rows below md and three to six rows from md upward. Keep the minimum at or below the maximum at each breakpoint. Set these variables on Composer so both the input and its scroll area inherit them.

Controls beside a short message

Set layout="adaptive" to place controls beside a one-line draft. When text wraps or you add a line break, the input takes the full width and the controls move below it. Both layouts use the same composition: ComposerInput followed by ComposerActions, with action groups aligned to start and end. Only the layout prop changes.

The adaptive layout starts at one visible row; setting --composer-min-rows above one keeps the controls below. Keyboard focus follows the same order in both layouts: input, leading actions, then trailing actions.

For a native textarea, use the same layout and render the textarea in ComposerInput:

Mentions and actions

The same Composer composition supports mentions and slash actions.

To extend the anatomy above, pass triggers={triggers} to Composer and insert these parts immediately after ComposerInput. The demo's Code tab shows the complete composition.

Configure only the triggers you need. Queries can contain spaces: @sam r finds Sam Rivera. A space immediately after the trigger closes the suggestions. Selecting an item inserts a token into the draft; it does not send the message or execute an action.

To open a trigger from a button inside ComposerActions, call useComposerContext().insertTrigger("@") or insertTrigger("/"). The trigger is inserted at the caret and opens the same suggestions as typing it.

Omit triggers and the popup parts for plain contenteditable text. Use value and onValueChange for a controlled draft, or defaultValue for an uncontrolled draft. Submission does not clear the structured draft automatically.

Token appearance

By default, selected mentions and actions appear as text-primary font-medium text with their trigger (@Sam Rivera or /Summarize). To change their appearance, render each selected token from ComposerInput. This example uses the same people, teams, and actions as the one above, but gives them rounded chips with different icons.

The selected item's kind reaches renderToken, so a person and a team selected from the same @ menu can use different icons or styles. The structured draft keeps type, kind, id, label, and trigger; getComposerText(value) still returns text such as @Sam Rivera when a chip hides the trigger visually. An item's icon is available to the suggestion list renderer but is not stored in the draft. A native textarea remains plain text and does not render tokens.

Custom filtering

The default filter ignores case and accents. Set a trigger's filter to customize matching. useComposerFilter exposes Base UI's contains, startsWith, and endsWith helpers.

Async results

Fetch results in response to onQueryChange. Use filter: null for prefiltered results and itemsFor to identify the query they belong to. A completed query with no matches closes the popup.

Keyboard shortcuts

While suggestions are open, ↑ and ↓ navigate, Enter selects, Escape dismisses, and Tab moves focus normally. Otherwise, Enter submits and Shift+Enter inserts a line break in the native contenteditable input or textarea. Set submitOnEnter="desktop" to submit only with a fine pointer, or "never" to let Enter insert a line break.

In a drawer

Bottom drawers automatically keep the input above the software keyboard. Place the composer after the scrolling DrawerPanel so it stays visible. Set the drawer height in your composition; Drawer handles keyboard and safe-area spacing automatically. Leave the Composer free to shrink in the drawer: its input scrolls in the remaining space while the action row stays visible.

API Reference

Composer

The styled frame and state owner for one input. It renders a form by default and works without any state props. Use render={<div />} when embedding it inside another form.

ComposerInput

The editable input with an internal coss ScrollArea. It connects to its parent Composer automatically. Give it an accessible name with aria-label or aria-labelledby.

Set the following CSS variables on Composer using className. Row counts follow the input's actual line height, including the change from text-base below md to text-sm on larger screens. Vertical padding is included automatically. A rendered textarea does not need a native rows attribute.

The maximum height is also capped at 50dvh. The four-row mobile default leaves more room for surrounding content, such as a conversation inside a drawer.

The default div has multiline textbox semantics. A rendered textarea uses the same parent-owned draft and submission policy. Use one ComposerInput per Composer. Forward supplied props and the ref when rendering a custom wrapper around the native textarea.

ComposerToken

A presentational token for renderToken or read-only previews. Pass the selected token and choose its appearance per item.

ComposerActions

Contains the action groups in either layout. Place it after ComposerInput so controls remain outside the scrolling draft. The standard layout always shows a bottom row; the adaptive layout places its groups beside short drafts and below longer ones. Apply button spacing and appearance to the groups and buttons.

ComposerActionGroup

A group of controls within ComposerActions. Put the leading group first and the trailing group after it in both layouts.

ComposerSubmit

A submit control that renders a native button by default. Pass a coss Button through render to choose its appearance, icon, and accessible name. It uses the parent's submission policy for either input. Setting disabled changes this button's state; it never bypasses the parent submission gate.

ComposerPopup

A styled Base UI Popover popup with its portal and positioner included. Composer controls its visibility.

The popup anchors to the Composer frame and preserves editor focus automatically. Pass anchor to override its position.

ComposerList

A scrollable result surface. It supplies listbox semantics and maps its items through a render function.

ComposerItem

A styled button for a result. Pass value to connect selection and keyboard highlighting in its parent Composer.

ComposerStatus

A status message inside a popup, such as loading feedback.

Drafts and triggers

Draft and trigger types

Item IDs must be unique within each trigger. Set disabled: true on an item to skip it during keyboard navigation and prevent selection. type defaults to "mention"; set "action" for slash actions. Use kind for your own categories, such as "person" and "team", even when both come from @. The selected part retains kind. getComposerText(value) returns the plain-text representation with trigger characters, including when chips hide them visually.

ComposerTrigger configures one source:

Draft lifecycle

Copying or cutting a token includes its trigger in plain text, even when a chip hides it. Pasted or dropped text stays plain text and never acquires a mention ID. Keep application records keyed by the token's id and kind; display icons are not part of the submitted document.

Contenteditable edits have a local undo history, including token insertion, paste, and cut. Replacing the draft externally or resetting it starts a new history. Selection is preserved and clamped to the updated draft. If an external value changes during IME composition, it is applied after composition finishes. A native textarea keeps its browser editing history.

ComposerChangeDetails.reason is "input", "selection" (selecting a suggestion), "history" (contenteditable undo/redo), or "reset".

useComposerContext

Import this hook from the same Composer module and use it inside a descendant component to build custom controls.

Input event handlers run before Composer's handlers. Call event.preventDefault() to override a supported keyboard or clipboard action. Base UI's event.preventBaseUIHandler() also stops the merged internal handler. Keep application business rules on Composer so they apply to both keyboard and pointer submission.

coss.com ui

Built by and for the team of Cal.com, Inc. — the leading commercial open source company (“coss”).