Skip to main content

Composer

Composerai

The standard chat input — a PromptInput pre-assembled with attachments, tools and submit.

import { Composer } from "@elabs-ai/components-ai";
Install
npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-ai

Skipping the @source line is the most common first failure: Tailwind does not scan node_modules, so every component renders unstyled. The one-time wiring

Prompt for your coding agent

Paste it into Claude Code, Cursor, Codex or any other coding agent. Text in [brackets] is yours to fill in.

Add the brand-ui Composer component (from @elabs-ai/components-ai) to this project.

1. Read https://elabs-ai.com/llms.txt first: it maps every package and the rules. If you can use MCP servers, connect https://elabs-ai.com/mcp and use its search, docs and tokens tools. If not, run the same lookups in the terminal: `npx -y @elabs-ai/components-cli search "<what you need>"` and `npx -y @elabs-ai/components-cli docs <Component>`.
2. Look Composer up before writing code: `npx -y @elabs-ai/components-cli docs Composer` prints its real props, variants, what it pairs with and what to avoid.
3. If @elabs-ai/components-ai is not installed yet: `pnpm add @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-ai` (or `npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-ai`). If this is the first brand-ui package in the project, wire it once: import `@elabs-ai/components-tokens/styles.css` plus the `themes/light.css` and `themes/dark.css` stylesheets in the CSS entry, add one Tailwind `@source` line per installed @elabs-ai package (Tailwind does not scan node_modules, so without it everything renders unstyled), and wrap the app root in `ThemeProvider` from `@elabs-ai/components-tokens`.
4. Use it here: [the screen or file, and what it should show]
5. Rules: use only @elabs-ai/components-* components and the semantic tokens — no raw colours, no raw font sizes, no arbitrary Tailwind values. Look up each component's real props with the docs tool before you use it; never guess a prop.
6. Done means: the typecheck passes, `npx -y @elabs-ai/components-cli audit src --strict` reports nothing, and you have opened the app and looked at every screen you touched in the light and the dark theme.

Avoid

  • Hand-rolling a PromptInput footer per app — Composer is the shipped assembly; reach for it first.
  • Dropping to PromptInput to get a mode, effort or slash-command control — Composer exposes all three as props (mode, effort, slashCommands/onSlashCommand); drop to PromptInput only for a bespoke shell.
  • Expecting a model name in the footer by default — the old static `model` pill is gone; pass a ModelPicker (@elabs-ai/components-ui) to the `modelPicker` slot.
  • Passing children to PromptInputSubmit for every status — the Stop affordance goes invisible while generating; pass it for the resting state only.
  • Treating the composer as a controlled textarea — it is a form; use onSubmit, not value/onChange plumbing.
  • Reaching for surfaceClassName on Composer — it has no such prop; className styles the outer card frame and Composer owns the inner well's shape (surfaceClassName lives on PromptInput).

Composer — the standard brand-ui AI chat input. The canonical two-tone "double card": an outer bg-card frame with a muted status strip wrapping a recessed PromptInput well (sharp top, theme-rounded bottom), a tools cluster, voice, and a circular send. Composer is the chat input. Every control the PromptInput family ships is reachable from a Composer prop; drop to PromptInput only for a bespoke shell. The four slots that make that true — modelPicker, mode, effort and slashCommands — are the stories below; the footer order is attach · modelPicker · mode · effort │ voice · send, the same arrangement TerminalComposer uses, so the chat and console skins agree. ### Anatomy — and what the pages nested under this one are A Composer is a PromptInput form: a muted status strip above the well, the well itself, and a tools cluster in the footer. The sub-pages under AI/Composer are those parts, not siblings of it: - PromptInput — the raw composer FORM primitive everything here is built on. Compose it yourself only for a bespoke shell. - PromptInputMode, PromptInputEffort, PromptInputSlash — the three controls the mode, effort and slashCommands slots render. The fourth slot, modelPicker, takes a ModelPicker from @elabs-ai/components-ui, which is why it has no page of its own here. - WithMentionInput — the mentions slot: an @-mention roster (MentionInput from @elabs-ai/components-ui) on the composer's field. Terminal/TerminalComposer is the console skin of this same family — same footer order, different surface. Semantic tokens only; theme-aware radii; reads in every theme.

Works with

Contains

Used inside

  • ChatShell

Examples

15 live examples

Double Card Toned

`tone="card"` (#254): the tinted-outer/distinct-inner "double card" — an outer `bg-surface-muted` frame around a `tone="card"` well, instead of Composer's default outer `bg-card` frame. Check both themes: the well is raised (lighter than the frame) on light themes, recessed (darker) on dark.

Empty State Scene

The empty/first-run chat state: a centered greeting + composer + suggestion chips.

Streaming

While the agent is generating: a status line + the streaming send (stop) state.

With Shortcuts

`shortcuts` — a row of keyboard-shortcut hints beneath the well (#107). Opt-in: nothing renders unless you pass at least one. At rest only the always-shown hints appear.

With Shortcuts Busy

The hints CHANGE with a busy state (#107's acceptance criterion, verbatim): `cancelShortcut` joins the row only once the composer is actually generating (`sendStatus="streaming"`) AND a real `onStop` is set — a hint for an affordance that isn't there is never shown.

Streaming With Draft

Running, but the user has already typed a follow-up (#351's P0 fix): the control flips back to the circular ArrowUp `sendIcon` — never the Stop square — and submits normally, letting the app decide what a mid-turn submit means. `sendIcon` (not `children`) is what survives the flip; see `Composer`'s own docblock.

Send States

Every send-button state at once — the affordance grid. At rest the send is an ArrowUp and is **disabled** until there is text or an attachment (an enabled-looking button that refuses to send is an a11y lie). While generating it becomes the Stop square; on error, an X that stays clickable so the user can retry. Use this story to check the disabled circular send across both themes — the primary fill and the disabled opacity interact.

Minimal

Trimmed to the bone: no status strip, no voice — just attach + send.

Refused Submit

A consumer-refused send: `submitProps.disabled` closes BOTH the click and the Enter route to submit. Why it matters: `PromptInput`'s submit handler calls `form.reset()` the moment it ACCEPTS a submit, so an app that refuses the send inside `onSubmit` — one doing async setup before the first message, say — finds the textarea already cleared and the user's question destroyed, with nothing to restore from. Disabling the control is what actually prevents that. Type into the field: the text stays, and nothing submits. Note `disabled` must be left UNSET (not `false`) when you have no opinion — `PromptInputSubmit` resolves `disabled ?? autoDisabled`, so a literal `false` opts out of the library's own empty-composer guard.

With Mode

`mode` — an app-defined operating mode (how autonomously the agent may act on the next turn), rendered as a `PromptInputMode` in the tools cluster. brand-ui ships no mode vocabulary; `modes` is entirely yours.

With Effort

`effort` — an ORDERED reasoning-effort scale, rendered as a `PromptInputEffort`. The squares grow low → high and fill up to the current level, so the level survives a greyscale render; the level's name is also rendered as text. `aria-label` names the scale and is required — the name of the scale is as much your vocabulary as its rungs are.

With Slash Commands

`slashCommands` — typing `/` at the start of a line opens a filtered command palette without focus ever leaving the field. Setting the prop swaps the textarea for a `PromptInputSlashTextarea` inside the same body; nothing else about the composer changes.

With Model Picker

`modelPicker` — the slot that replaced the old static `model` pill. Pass a `ModelPicker` from `@elabs-ai/components-ui` (it is sized to sit in a composer footer) wired to your own state; Composer owns only the slot and its position. A composer that shows a model name it cannot change is a lie about what the control does, which is why nothing renders here by default.

Everything

Everything at once — the full footer in its canonical order: `attach · modelPicker · mode · effort │ voice · send`, the same left-to-right arrangement `TerminalComposer` uses. The cluster wraps rather than overflowing, so check this one at a narrow width and in both themes.

API

Composer

PropTypeDescription
onSubmitPromptInputProps["onSubmit"]Submit handler — receives the assembled message (text + attachments).
placeholderstringTextarea placeholder. Default `"Ask me anything…"`.
statusReactNodeMuted status line shown above the input well. Default `"Awaiting your input"`. Drive it from chat state ("Thinking…", "Generating…") or pass `null` to hide the strip entirely.
modelPickerReactNodeThe model control, rendered first in the tools cluster after `attach`. Pass a `<ModelPicker>` from `@elabs-ai/components-ui` (it is sized to sit in a composer footer) — or anything else. Default: nothing renders. This replaces the old `model` prop, which rendered a static, non-interactive pill defaulted to a hard-coded model name. A composer that shows a model name it cannot change is a lie about what the control does; the slot now takes the real picker instead.
modeComposerModePropsApp-defined operating mode control — renders a `PromptInputMode` in the tools cluster. Omitted, nothing renders. `brand-ui` ships no mode vocabulary: `modes` is entirely yours.
effortComposerEffortPropsOrdered reasoning-effort control — renders a `PromptInputEffort` in the tools cluster. Omitted, nothing renders. `levels` is ordered low → high and is entirely yours; `aria-label` names the scale.
toolsReactNodeOverride the DEFAULT tool buttons (today: the attach button) — e.g. to add a web-search or connect-data control. Render `PromptInputButton`s / `PromptInputActionMenu` here; when set, `showAttach` is ignored. The explicit `modelPicker` / `mode` / `effort` slots are NOT part of this override — they render after whatever this puts in the cluster, so passing `tools` can never silently swallow a control you asked for by name.
sendStatusComponentProps<typeof PromptInputSubmit>["status"]Send-button state, forwarded to `PromptInputSubmit` (ready/submitted/streaming/error).
onStopComponentProps<typeof PromptInputSubmit>["onStop"]Stop handler used while the send button shows the generating state.
shortcutsComposerShortcut[]Keyboard-shortcut hints shown as a row beneath the input well — plain `Kbd` + label pairs, e.g. `[{ keys: "Enter", label: "send" }]`. Omitted, nothing renders (opt-in, like every other Composer slot); the row is never invented, since the real bindings are the host app's.
cancelShortcutComposerShortcutA second, busy-only hint appended to `shortcuts` (#107) — shown ONLY once the composer is actually generating (`sendStatus` is `"submitted"` or `"streaming"`) AND a real `onStop` handler is set, so the hint never describes an affordance that isn't there. Mirrors `TerminalComposer`'s `canCancel = busy && Boolean(onStop)` shape.
submitPropsOmit<ComponentProps<typeof PromptInputSubmit>, "status" | "onStop">Extra props spread onto the send button — `disabled`, `aria-label`, `variant`, `id`, `className`. `status` and `onStop` are excluded because `Composer` owns them via `sendStatus` / `onStop` above. (A `data-*` key is not assignable through this object: TypeScript only permits arbitrary `data-*` on a JSX element directly, not on a typed props object. Reach for `id` or `aria-label` as the test hook.) **`disabled` is the one that matters for correctness.** `PromptInput`'s submit handler calls `form.reset()` as soon as it ACCEPTS a submit, so a consumer that refuses the send inside `onSubmit` — an app doing async setup on the first message, say — has already had the textarea cleared and the user's text destroyed, with nothing to restore from. Disabling the control is what actually prevents it: `PromptInputTextarea`'s Enter handler checks `submitControl?.disabled` and bails before `requestSubmit()`, so the Enter path is closed too, not just the click. It holds with a slash palette open as well — the palette's own Enter handling only ever inserts a command. Leave it UNSET rather than passing `false` when you have no opinion — `PromptInputSubmit` resolves `disabled ?? autoDisabled`, so a literal `false` opts out of the library's own empty-composer guard.
tonePromptInputProps["tone"]The two-tone arrangement, forwarded to the `tone` prop of `PromptInput`. `"surface"` (default, unchanged) is the original Composer look — an outer `bg-card` frame around the standard muted `PromptInput` well — so every existing usage is unaffected. `"card"` swaps to the tinted-outer/ distinct-inner "double card" (#254): an outer `bg-surface-muted` frame around a `tone="card"` well. The well fill relative to the frame is theme-driven, not universally "white" — raised on light themes, recessed on dark; see the `tone` prop doc on `PromptInput`.
suggestionsstring[]Optional suggestion chips rendered beneath the composer.
onSuggestionClick(suggestion: string) => voidClick handler for a suggestion chip.
showVoicebooleanShow the voice button. Default `true`.
showAttachbooleanShow the attach button. Default `true`.
classNamestringExtra classes for the outer card frame.

Also accepts: ComposerBaseProps, ComposerFieldProps

PromptInput

PropTypeDescription
acceptstringe.g., "image/*" or leave undefined for any
multipleboolean
globalDropbooleanWhen true, accepts drops anywhere on document. Default false (opt-in).
syncHiddenInputbooleanRender a hidden input with given name and keep it in sync for native form posts. Default false.
maxFilesnumberMinimal constraints
maxFileSizenumberbytes
tone"surface" | "card"The fill of the inner well (the `InputGroup` `variant`). `"surface"` (default) is the standard muted composer look; `"card"` renders a `bg-card` well for nesting inside an already-tinted outer frame — the "double card" look (#254). e.g. `<div className="rounded-xl bg-surface-muted p-1.5"><PromptInput tone="card">…</PromptInput></div>`. The well fill is NOT universally "white": `--card` reads lighter (raised) than `--surface-muted` on light themes, but darker (recessed) on dark — the same theme-dependent trade-off documented for `bg-surface-muted`-as-a-well in `styling-and-tokens.md`. The well stays a legible, distinct tone against the outer frame in every theme; only the "raised" framing is light-themes-specific.
onError(err: { code: "max_files" | "max_file_size" | "accept"; message: string }) => void
onSubmitrequired( message: PromptInputMessage, event: FormEvent<HTMLFormElement>, ) => void | Promise<void>
surfaceClassNamestringClasses for the inner surface (the visible `InputGroup` well), e.g. to shape its corners. `className` styles the outer `<form>`; this styles the well — the only way to reach it from outside (used by `Composer`).

Also accepts: Omit<HTMLAttributes<HTMLFormElement>, "onSubmit" | "onError">

PromptInputTextarea

Also accepts: ComponentProps<typeof InputGroupTextarea>

PromptInputTools

Also accepts: HTMLAttributes<HTMLDivElement>

PromptInputSubmit

PropTypeDescription
statusChatStatus
onStop() => void
sendIconReactNodeGlyph for the SEND action only — survives the send↔stop flip, so a consumer's resting glyph (e.g. `Composer`'s circular arrow) is no longer lost while generating with a non-empty composer. Prefer this over `children`.
childrenReactNode@deprecated Replaces the glyph for **every** status — ready, submitted, streaming AND error — which is why generating/error affordances can disappear even though the control is still live. Prefer `sendIcon`, which only ever replaces the resting Send glyph.

Also accepts: ComponentProps<typeof InputGroupButton>

PromptInputMode

PropTypeDescription
modesrequiredOperatingMode[]The app's own operating modes, low to high ceremony or however it orders them.
valuestringControlled selected mode id. Omit for the uncontrolled default (`modes[0]`).
onValueChange(id: string) => voidFires with the chosen mode's id.

Also accepts: Omit<HTMLAttributes<HTMLDivElement>, "onChange">

PromptInputEffort

PropTypeDescription
levelsrequiredEffortLevel[]Ordered low → high. The order IS the semantics — render in this order.
valuestringControlled selected level id. Omit for the uncontrolled default (`levels[0]`).
onValueChange(id: string) => voidFires with the chosen level's id.

Also accepts: Omit<HTMLAttributes<HTMLDivElement>, "onChange">

PromptInputSlash

PropTypeDescription
commandsrequiredSlashCommand[]The registered commands the palette offers.
valuerequiredstringThe composer's current text — must match what the wrapped textarea renders.
textareaRefrequiredRefObject<HTMLTextAreaElement | null>Ref to the composer's `<textarea>` (usually the one `PromptInputSlashTextarea` renders).
lineStartOnlybooleanOnly trigger at the start of a line (index 0, or right after `"\n"`). `false` falls back to `MentionInput`'s word-boundary rule. @default true
filter(command: SlashCommand, query: string) => booleanWhich commands survive the current query. @default defaultSlashCommandFilter
onValueChangerequired(next: { text: string; caret: number }) => voidA command was chosen — apply the spliced `{ text, caret }` to your own controlled value (the same shape `replaceTriggerRun` returns). The splice inserts `"/" + command.name + " "` in place of the typed query.
onSelect(command: SlashCommand) => voidA command was chosen (fires alongside, not instead of, `onValueChange`).
onOpenChange(open: boolean) => voidThe popup opened or closed.
childrenrequiredReactNode

Also accepts: Omit< HTMLAttributes<HTMLDivElement>, "onSelect" | "children" >

Theming

The tokens each state resolves to. Change the token and every theme follows.

StateToken
submitstatus=ready|submitted|streaming|error