Composer states
NewA chat composer in four states: a one-line pill, a box that grows as the message wraps, a dictation row with a live waveform, and tools riding as chips.
composer-states/composer-states.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- The message box at the foot of a chat with an agent, where most messages are one line and some are a paragraph.
- A conversation where the model, its effort and a few tools are picked per message, right where it is written.
- Products where speaking a message is as likely as typing it: dictation lives in the same row.
- A narrow column or a phone: below 140 px of room beside the buttons it simply stays a box.
When not to use
- A search field or a one-line command. Use a plain input or a command palette; nothing here needs to grow.
- Long-form writing with formatting. Use an editor: the box stops growing at six lines and scrolls.
- Attaching files, mentions or images. This row carries two tool chips; use a composer built for attachments.
- A form field with a label, validation and a submit button of its own.
You can add this component with the shadcn CLI or by hand:
Into components/composer-states of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/composer-states.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { ComposerStates } from './composer-states';export function AgentComposer({ onSend }: { onSend: (message: string) => void }) { return ( <ComposerStates defaultModel="nova-3" defaultEffort="High" onSend={(message, { model, effort, tools }) => { console.log(model, effort, tools); onSend(message); }} autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
One composer that moves between its states. Empty, it is a pill with a +, the model picker, a microphone and send; as the message wraps it springs open into a box, the text above and the buttons below. The + attaches tools as tinted chips that pop in and out. The microphone morphs the row into dictation: the + turns into a cancel ×, the microphone into a stop square, and a dotted line comes alive as a simulated voice writes into the message. Enter sends, and the box springs back down.
- One shell, two layouts. Inline, the text runs from the + button to 10 px before the model picker; stacked, it takes the top and the buttons a 52 px row of their own. The row is pinned to the shell’s foot in both and the stage holds the foot still, so the shell grows upwards and only the text moves.
- The layout is measured, not guessed: a hidden copy of the message in the same type gives its width on one line (does it fit inline?) and its lines at the stacked width (how tall?). A ResizeObserver keeps both right as the shell changes width. The box grows to six lines, then scrolls; with under 140 px of room beside the buttons (a phone) it stays a box even when empty.
- A change of layout is played from a layout effect before paint: the shell’s height springs from where it was to where it now is, and the text slides from its old left edge to its new one (a FLIP of 38 px). Both are Web Animations whose easing is a damped spring (stiffness 420, damping 34) stepped at 240 Hz and written as a CSS linear() curve; an animation cut short starts the next one from its current height.
- Chips spring open in their own width, so the row makes room for them as they pop from half size (a livelier spring, damping 26), and close in 260 ms before they leave the DOM. With a chip in the row the composer stays stacked.
- Dictation is CSS transitions on one attribute. The + turns 45° and fades as the × turns in from −45°, the microphone shrinks away as the stop square grows, each on a 35 px disc that scales in; the model picker and chips blur 4 px and fade, and become inert; the waveform opens from its middle with a clip-path over 520 ms.
- The waveform is a canvas: a slot every 6.5 px, a 2.5 px dot for silence and a bar up to 20 px for speech, sliding left one slot every 82 ms and smoothly in between. There is no microphone: the voice is built from the words it will say, two bars a syllable, phrases parted by runs of dots, each phrase’s first and last three bars dimmer. Each word is written into the message once its last bar is in view, so the transcript keeps time with the line. Its two colours are read from the canvas’s own CSS, so it follows the scheme.
- Stop keeps what was heard; cancel puts the message back as it was. Sending lifts a copy of the message up 14 px as it blurs and fades, the arrow shoots out of the top of the send button and comes back from below, and the shell springs back to its new height.
- The model menu lists the models with a note each and the effort as a row of three; the + menu lists the tools, checked when attached. Both pop from their button’s edge in 180 ms and keep one highlight that follows the pointer, the arrows or the demo.
- While on screen and untouched, a demo plays a 22 s round through all four states: it types the message, attaches both tools, looks through the models, dictates a sentence, stops, sends and removes the chips. It never moves the focus; its changes never reach the callbacks and are not announced, and any press or key hands the composer to the reader.
- Every colour is a light-dark() token on the root: the night is the composer’s own near-black stage and #262626 shell, the day a white shell with a hairline and a soft lift on a pale stage, the same violet send on both.
| Prop | Type | Default | Description |
|---|---|---|---|
models | ComposerModel[] | three sample models | What the model picker offers: { id, label, note? }. |
defaultModel | string | the first model | The model chosen at first. |
defaultEffort | 'Low' | 'Medium' | 'High' | 'High' | The effort chosen at first, shown after the model’s name. |
onModelChange | (model: string, effort: ComposerEffort) => void | — | Called when the reader picks another model or effort. |
defaultTools | ('web' | 'visual')[] | [] | Tools attached at first, shown as chips. |
onToolsChange | (tools: ComposerTool[]) => void | — | Called with the attached tools whenever one is attached or removed. |
defaultValue | string | '' | The message at first. |
defaultState | ComposerStatesState | — | Opens the composer mid-way: menu ('tools' or 'model') with active, its lit row, or dictating, the waveform held still and hearing nothing until the composer is used. Setting it turns the demo off. |
placeholder | string | 'Ask about anything…' | The empty composer’s words. |
listeningLabel | string | 'Listening…' | The empty composer’s words while dictating. |
dictation | string | 'Keep it short, and put the fixes first.' | What the simulated dictation hears, written in word by word in time with the waveform. There is no microphone. |
onSend | (message: string, options: { model, effort, tools }) => void | — | Called with the trimmed message, the model, the effort and the tools when the reader sends. |
autoplay | boolean | true | While on screen and untouched, a demo plays the four states by itself. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useEffectEvent, useLayoutEffect, useRef, useId, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent,} from 'react';import { EFFORTS, MODELS, SAMPLE_DICTATION, SAMPLE_MESSAGE, TOOLS, type ComposerEffort, type ComposerModel,/* Composer states (composer-states.tsx): a near-black stage with the composer low in its middle, 568 px wide at most, its menus opening above it; by day a soft white stage and a white composer with a hairline and a soft lift. Sizes: the pill is 52 px tall, a 26 px line with 13 px above and below, and its round buttons are 36 px, 8 px in from its ends. Stacked, the text keeps that first line and the buttons' row (52 px) sits 11 px under the last one. The corners are 26 px in both, so the pill's ends are round and the box's corners keep the same curve as it grows. Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). Each shadow is one list for both schemes, each layer coloured for one of them and clear in the other. Motion: the shell's height and the text's slide are springs played from the script (Web Animations); the row's morph into dictation is CSS transitions on data-dictating; the chips spring in their own width; the waveform is a canvas. */.root { /* The stage, the composer, the menus, and a hovered button. */ --canvas: light-dark(#f7f7f7, #0a0a0a); --line: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.04)); --surface: light-dark(#fff, #262626); --menu: light-dark(#fff, #262626);// Copyright (c) 2026 Bruno Delorence. MIT License./* * The dictation row's waveform: a dotted line that a voice raises into bars. Slots sit every * 6.5 px; silence is a 2.5 px dot, speech a 2.5 px bar up to 20 px tall. The newest slot comes in * at the right edge and the line slides left one slot per beat, smoothly between beats. * * There is no microphone. The voice is simulated from the words it is meant to say: each word * gives two bars a syllable, phrases are parted by a run of dots, and each word is handed to * `onWord` once its last bar is in view, so the transcript keeps time with the line. Within a phrase * the bars are bright, but for the three at either end, which trail in and out dimmer. * * Drawn on a canvas: one small layer, repainted each frame while it is on screen. Its two colours * are read from the canvas's own CSS (color and border-color), so light or dark follows the page. *//** Where the slots fall, in CSS px. */export const PITCH = 6.5;const BAR = 2.5;const DOT = 2.5;/** The tallest bar; the canvas is drawn a little taller so its round ends are never clipped. */const TALL = 20.2;export const WAVE_HEIGHT = 24;/** One slot per beat. */// Copyright (c) 2026 Bruno Delorence. MIT License./* * Springs for the Web Animations API. A damped spring is stepped from 0 to 1 until it settles, and * its path is written as a CSS linear() easing, so the browser plays it like any other curve, off * the main thread where it can. Where linear() is not understood, a fast-out cubic stands in. */export type Spring = { duration: number; easing: string };const cache = new Map<string, Spring>();/** A spring with this stiffness and damping (mass 1), as a duration in ms and an easing. */export function spring(stiffness: number, damping: number): Spring { const key = `${stiffness}/${damping}`; const known = cache.get(key); if (known) return known; const step = 1 / 240; let x = 0; let v = 0; let t = 0; const path: number[] = [0]; // Steps of 1/240 s; one point every four of them (60 a second) is plenty for linear(). for (let i = 1; i < 240 * 3; i++) {// Copyright (c) 2026 Bruno Delorence. MIT License./* * The composer's icons, drawn on a 20-unit grid and stroked with the text colour. The CSS gives * every one of them its size (20 px, or 16 px in the menus), so a page rule about svg never * resizes them. All are decorative: the buttons carry the names. */const stroke = { fill: 'none', stroke: 'currentColor', strokeLinecap: 'round', strokeLinejoin: 'round', viewBox: '0 0 20 20', 'aria-hidden': true, focusable: false,} as const;export function PlusIcon({ className }: { className?: string }) { return ( <svg className={className} {...stroke} strokeWidth={1.75}> <path d="M4.3 10h11.4M10 4.3v11.4" /> </svg> );// Copyright (c) 2026 Bruno Delorence. MIT License./* The composer's sample models, effort levels and tools, and the words its demo writes. */export type ComposerModel = { id: string; label: string; note?: string };export type ComposerEffort = 'Low' | 'Medium' | 'High';export type ComposerTool = 'web' | 'visual';export const MODELS: ComposerModel[] = [ { id: 'nova-3', label: 'Nova 3', note: 'Best for complex work' }, { id: 'nova-3-mini', label: 'Nova 3 Mini', note: 'Fast for everyday tasks' }, { id: 'orbit-2', label: 'Orbit 2', note: 'Long documents and code' },];export const EFFORTS: ComposerEffort[] = ['Low', 'Medium', 'High'];export const TOOLS: { id: ComposerTool; label: string; detail: string }[] = [ { id: 'web', label: 'Web search', detail: 'Look things up online' }, { id: 'visual', label: 'Visual search', detail: 'Find what is in an image' },];/** What the demo types, and what dictation hears when no onDictate is given. */export const SAMPLE_MESSAGE = 'Use /release-notes to draft the notes for version 4.2 of the mobile app.';'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The message is a real textarea named “Message”: Enter sends, Shift+Enter starts a new line. Tab goes from it to the +, the chips’ remove buttons, the model picker, the microphone and send, in the order they are seen.
- The + and model buttons say they open menus and whether they are expanded; Enter, Space or the up arrow opens a menu with the focus on its first or chosen entry. Arrows, Home and End move, Enter picks, Escape closes and returns to the button, Tab closes. Tools are menuitemcheckbox entries and models and efforts menuitemradio ones, each with aria-checked.
- Each chip names its tool to screen readers and has a remove button (“Remove web search”) in a group named “Attached tools”; removing one returns the focus to the message.
- While dictating, the + button becomes “Cancel dictation” and the microphone “Stop dictation”, in the same places, so the focus stays put; the model picker and chips are inert. Send says it is disabled (aria-disabled) while there is nothing to send.
- A polite live region says what changed: a tool attached or removed, the model or effort chosen, listening, dictation stopped or cancelled, a message sent. Focus rings are visible on every button; the waveform and icons are hidden from screen readers.
- Under reduced motion the shell changes size at once, chips and menus appear without popping, the row swaps without a morph, the waveform stands still at its resting line (the words still arrive), and the demo never plays.