Composer
When to Use
- A free-text box the user fills and submits in place — add a note, reply on a thread, leave a comment, give a rejection reason
- The prose IS the interaction; any other controls only qualify how the text is submitted
Layout
+---------------------------------------------------------+
| Card.Root / Card.Content |
| +-----------------------------------------------------+ |
| | Textarea | |
| | | |
| +-----------------------------------------------------+ |
| [ options slot … ] [Discard] [ Send ] |
+---------------------------------------------------------+Two rows inside one Card:
- Body — a single
Textarea, sized withrows. Tall enough to read as "write here", short enough that the action row stays on screen. - Action row — one
flex items-center justify-betweenline with a fixed right side and a free left side.
The action row
This is the part the pattern actually fixes. Everything sits on one line, never stacked.
Right side — fixed. Reading left to right: an optional variant="ghost" Discard, then exactly one primary submit. Nothing else goes here.
- Discard clears the body; it does not close or navigate. Omit it when there is nothing to discard back to.
- The submit is the view's single primary
Button— never a second filled button beside it. - Both disable while
submitting; the submit also disables on empty/whitespace-only input. - Swap the submit's label for a pending label while in flight ("Send" → "Sending…"). Do not swap in a spinner-only button — the label is what tells the user what is happening.
Left side — open. Zero or more controls that qualify the submission, laid out flex items-center gap-2 and allowed to shrink (min-w-0) so the buttons never get pushed off. Common occupants:
- a
Checkboxtoggle — visibility, "internal note", "notify watchers" (the worked example below) - an attachment or template
Buttonwithvariant="ghost" - a small
Select— reply-as, channel, canned response
Leave it empty and the buttons still sit correctly at the right; justify-between needs no placeholder element.
Page Implementation
tsx
function FormComposer() {
const [body, setBody] = useState("");
const [internal, setInternal] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const handleSubmit = () => {
window.alert(`Sending ${internal ? "internal note" : "reply"}: ${body}`);
setErrors({});
setBody("");
};
return (
<Card.Root>
<Card.Content>
<Form noValidate errors={errors} onFormSubmit={handleSubmit} className="space-y-3">
<Field.Root name="body">
<Field.Label className="sr-only">Reply</Field.Label>
<Textarea
required
rows={4}
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={internal ? "Internal context for teammates…" : "Reply to customer…"}
/>
<Field.Error />
</Field.Root>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Checkbox
label="Internal note (not sent to customer)"
checked={internal}
onCheckedChange={setInternal}
/>
</div>
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="ghost"
onClick={() => {
setBody("");
setErrors({});
}}
disabled={body.length === 0}
>
Discard
</Button>
<Button type="submit" disabled={body.trim().length === 0}>
Send
</Button>
</div>
</div>
</Form>
</Card.Content>
</Card.Root>
);
}Constraints
- The body control is
Textarea, neverInput—Inputis locked toh-9and clips prose to one 36px line. - Size the body with
rows(3–5 is the usual range). Do not override its height with classes:astw:is internal to AppShell, andTextarea's supported sizing API isrows. - The
Textareaneeds an accessible name. There is usually no visible label in a composer, so use asr-onlyField.Label; a placeholder is not a label. Prefer this overaria-label— theField.Rootis already there, and a real label keeps the error and description wiring available. - One primary
Buttonin the action row. Discard isghost, and any left-side action isghosttoo. - The action row is one row at every width — the left side shrinks, the buttons do not wrap under the body.
- Disable the submit on empty or whitespace-only input, and disable both buttons while
submitting. - The composer is a
Form, not a bare widget. The body sits in aField.Root name="…", Send istype="submit", and Discard istype="button"— inside a<Form>an untyped<button>defaults tosubmit. - Keep the body controlled. It is read during render to gate Send and to swap the placeholder, which a submit-time handler cannot do. This is the one
form/*pattern where field state is load-bearing rather than redundant. - Route server rejections (moderation, rate limit, thread closed) through
Form'serrorsprop intoField.Error, not a toast. A composer that can be rejected needs somewhere for the reason to land next to the text the user still has. - Wrap the composer in
Card.Rootwhen it sits among other cards (a detail page, the end of a thread). A composer that is already inside aSheetorDialogbody does not need its own card.
Anti-patterns
Input(orField.Controlwith no override) for the body — a single-line box for multi-line text.- Discard rendered as
outlineor filled — it competes with the submit for the eye. - Submit and Discard both filled, or a third action added to the right side.
- The action row stacked above the
Textarea, or the buttons wrapped onto their own line — the composer stops reading as one unit. - A left-side toggle that silently changes what the submit does without changing the submit's own affordance — if "internal note" changes the destination, the placeholder (or the label) should say so.
- Submit enabled on an empty body, so the user can post nothing.
- A bare
<div>of controls with anonClicksubmit — noForm, so a server rejection has nowhere to go and the body has no field identity. - Clearing the body on a failed submit — the user loses what they wrote. Clear only after the submit succeeds.
- A body so tall (
rows={12}) that the action row falls below the fold — the composer stops reading as one unit.