Skip to content
View as Markdown

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:

  1. Body — a single Textarea, sized with rows. Tall enough to read as "write here", short enough that the action row stays on screen.
  2. Action row — one flex items-center justify-between line 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 Checkbox toggle — visibility, "internal note", "notify watchers" (the worked example below)
  • an attachment or template Button with variant="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, never Input — Input is locked to h-9 and 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, and Textarea's supported sizing API is rows.
  • The Textarea needs an accessible name. There is usually no visible label in a composer, so use a sr-only Field.Label; a placeholder is not a label. Prefer this over aria-label — the Field.Root is already there, and a real label keeps the error and description wiring available.
  • One primary Button in the action row. Discard is ghost, and any left-side action is ghost too.
  • 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 a Field.Root name="…", Send is type="submit", and Discard is type="button" — inside a <Form> an untyped <button> defaults to submit.
  • 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's errors prop into Field.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.Root when it sits among other cards (a detail page, the end of a thread). A composer that is already inside a Sheet or Dialog body does not need its own card.

Anti-patterns ​

  • Input (or Field.Control with no override) for the body — a single-line box for multi-line text.
  • Discard rendered as outline or 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 an onClick submit — no Form, 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.