import { Composer } from "@elabs-ai/components-ai";npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-aiPaste 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
Used inside
Pairs with
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
| Prop | Type | Description |
|---|---|---|
onSubmit | PromptInputProps["onSubmit"] | Submit handler — receives the assembled message (text + attachments). |
placeholder | string | Textarea placeholder. Default `"Ask me anything…"`. |
status | ReactNode | Muted 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. |
modelPicker | ReactNode | The 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. |
mode | ComposerModeProps | App-defined operating mode control — renders a `PromptInputMode` in the tools cluster. Omitted, nothing renders. `brand-ui` ships no mode vocabulary: `modes` is entirely yours. |
effort | ComposerEffortProps | Ordered 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. |
tools | ReactNode | Override 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. |
sendStatus | ComponentProps<typeof PromptInputSubmit>["status"] | Send-button state, forwarded to `PromptInputSubmit` (ready/submitted/streaming/error). |
onStop | ComponentProps<typeof PromptInputSubmit>["onStop"] | Stop handler used while the send button shows the generating state. |
shortcuts | ComposerShortcut[] | 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. |
cancelShortcut | ComposerShortcut | A 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. |
submitProps | Omit<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. |
tone | PromptInputProps["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`. |
suggestions | string[] | Optional suggestion chips rendered beneath the composer. |
onSuggestionClick | (suggestion: string) => void | Click handler for a suggestion chip. |
showVoice | boolean | Show the voice button. Default `true`. |
showAttach | boolean | Show the attach button. Default `true`. |
className | string | Extra classes for the outer card frame. |
PromptInput
| Prop | Type | Description |
|---|---|---|
accept | string | e.g., "image/*" or leave undefined for any |
multiple | boolean | |
globalDrop | boolean | When true, accepts drops anywhere on document. Default false (opt-in). |
syncHiddenInput | boolean | Render a hidden input with given name and keep it in sync for native form posts. Default false. |
maxFiles | number | Minimal constraints |
maxFileSize | number | bytes |
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> | |
surfaceClassName | string | Classes 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`). |
PromptInputTextarea
PromptInputTools
PromptInputSubmit
| Prop | Type | Description |
|---|---|---|
status | ChatStatus | |
onStop | () => void | |
sendIcon | ReactNode | Glyph 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`. |
children | ReactNode | @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. |
PromptInputMode
| Prop | Type | Description |
|---|---|---|
modesrequired | OperatingMode[] | The app's own operating modes, low to high ceremony or however it orders them. |
value | string | Controlled selected mode id. Omit for the uncontrolled default (`modes[0]`). |
onValueChange | (id: string) => void | Fires with the chosen mode's id. |
PromptInputEffort
| Prop | Type | Description |
|---|---|---|
levelsrequired | EffortLevel[] | Ordered low → high. The order IS the semantics — render in this order. |
value | string | Controlled selected level id. Omit for the uncontrolled default (`levels[0]`). |
onValueChange | (id: string) => void | Fires with the chosen level's id. |
PromptInputSlash
| Prop | Type | Description |
|---|---|---|
commandsrequired | SlashCommand[] | The registered commands the palette offers. |
valuerequired | string | The composer's current text — must match what the wrapped textarea renders. |
textareaRefrequired | RefObject<HTMLTextAreaElement | null> | Ref to the composer's `<textarea>` (usually the one `PromptInputSlashTextarea` renders). |
lineStartOnly | boolean | Only 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) => boolean | Which commands survive the current query. @default defaultSlashCommandFilter |
onValueChangerequired | (next: { text: string; caret: number }) => void | A 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) => void | A command was chosen (fires alongside, not instead of, `onValueChange`). |
onOpenChange | (open: boolean) => void | The popup opened or closed. |
childrenrequired | ReactNode |
Theming
The tokens each state resolves to. Change the token and every theme follows.
| State | Token |
|---|---|
| submit | status=ready|submitted|streaming|error |