Composer
Where the client writes to Okami: a liquid-glass pill with a textarea that grows as they type and an ink send button.
Playground
import { Composer } from "@/components/shared/composer";
<Composer
value={draft}
onValueChange={setDraft}
onSend={send}
placeholder="Type a message…"
sendLabel="Send"
dictate={{
transcribe: (audio) => transcribeRecording(audio, locale),
onError: (problem) => toast.error(t(`voice.${problem}`)),
labels: { start: "Dictate", cancel: "Cancel recording", stop: "Stop and edit", send: "Stop and send", transcribing: "Writing it down…" },
}}
beam
/>In the thread
Try it. Enter sends and Shift+Enter starts a new line; an IME composition is never cut short. While Okami's reply is on its way the send button waits and the TypingIndicator takes Okami's side of the thread — the composer adds no second indicator. Focus returns to the textarea when the reply lands.
import { ChatMessage } from "@/components/shared/chat-message";
import { Composer } from "@/components/shared/composer";
import { TypingIndicator } from "@/components/shared/typing-indicator";
{messages.map((m) => (
<ChatMessage key={m.id} from={m.from}>{m.text}</ChatMessage>
))}
{pending && <TypingIndicator label="Okami is typing" className="aura-rise" />}
<Composer
ref={inputRef}
value={draft}
onValueChange={setDraft}
onSend={send}
placeholder={t("placeholder")}
sendLabel={t("send")}
busy={pending}
/>Listening
While it dictates, the wave (VoiceWave) takes the text's place: a row of resting pills, as many as fit, that the voice raises smooth hills along where it lands. A loud moment makes a tall hill; each rises on --duration-micro and falls away on --duration-fast, so the row moves as the voice does. Under reduced motion the hills step in, still. Shown here without a microphone: the landing's ask as a real voice said it, over and over.
<Composer
value=""
onValueChange={() => {}}
onSend={() => {}}
placeholder="Type a message…"
sendLabel="Send"
dictate={{ transcribe, onError, labels }}
// Shown without listening: every level so far, one per 60ms.
shown={{ phase: "recording", levels, taken }}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | The draft. Controlled. |
| onValueChange | (value: string) => void | — | Called as the client types. |
| onSend | (value: string) => void | — | Called with the text to send on Enter or the send button — only when the draft isn't blank and nothing is busy or disabled. Sent straight from the microphone, it's the draft with what was said after it. |
| placeholder | string | — | Also the textarea's accessible name. |
| sendLabel | string | — | The send button's accessible name. |
| busy | boolean | false | A reply is on its way: typing stays open, sending waits. |
| surface | "glass" | "frost" | "solid" | "glass" | Liquid glass on the atmosphere; a frosted pill like a Chip's on a glass panel, where glass inside glass goes grey; solid white with a mild border on a white page, where glass only refracts what scrolls under it. |
| disabled | boolean | false | Neither typing nor sending. |
| attach | { onFile, label, accept? } | — | A paperclip before the text: onFile takes the picked file, label names the button (“Attach a price list”), accept is what the picker offers. Left out, the composer is text only. |
| dictate | { transcribe, onError, labels } | — | A microphone before send. While it listens a live wave (VoiceWave) takes the text's place: the voice raises smooth hills along a row of resting pills, a loud moment a tall one, each rising and falling away in about a third of a second. It steps under reduced motion. × drops the recording, ■ puts the words in the field to check, and ↑ sends them as they are. transcribe turns the recording into words (transcribeRecording posts it to /api/voice); onError hears “denied” or “failed”; labels name the five states. Stops by itself after two minutes. Hidden where the browser can't record. |
| shown | { phase, levels, taken? } | — | A dictation shown without listening, for a staged screen (the landing's owner demo): phase is “recording” or “transcribing”, levels the loudness so far, 0 to 1, oldest first, one per 60ms; taken how many in all, when levels keeps only the newest. Needs dictate for its labels. |
| beam | boolean | false | A beam riding the pill's edge in the studio's colours (the UI pair, --mix-studio and --mix-studio-2): an EdgeBeam (the full border), turning on the compositor; the border keeps its own colour: only light bleeding in from the edges near its head, travelling with it. Off under reduced motion. The owner's composer and the FloatingComposer wear it. |
| ref | Ref<HTMLTextAreaElement> | — | The textarea — to return focus after a reply. |
| className | string | — | Layout only, on the form — padding, safe areas. |
sendsOnEnter(event) is exported too: the key rule on its own, for any other text box that sends on Enter.
Guidelines
- One composer per conversation, pinned to the bottom of the page and clear of the safe area. Free text always stays open — quick-reply Chips are shortcuts, never a replacement.
- The composer is chrome, so it is the one place the true liquid glass (GlassPanel) is spent; the messages above it use the cheaper frost.
- Never clear the draft until it has been handed to
onSend, and hand focus back to the textarea once the reply lands.