Skip to content
KONIGI Patterns

AI Assistants / Starting a request / Composer

‹ 3 of 7 ›

Composer

People need space to write a prompt without losing sight of the conversation.

Updated September 12, 2026

Problem

People need to write short questions and long prompts in the same field without losing sight of the conversation.

Solution

Let the message field grow as people type, up to a limit that keeps the conversation visible. After that, scroll within the field.

Make the send shortcut clear. If Enter sends, use Shift+Enter for a new line and consider a preference for people who write longer prompts. Keep keyboard focus in the field after sending.

Save unfinished drafts when people switch conversations. Let them drop files onto a generous target area. For a large paste, offer to treat the text as an attachment so it remains manageable.

Keep frequently used controls near the field and move less common options into a menu.

Use when

People exchange text messages with an assistant and need to see earlier messages.

Don’t use when

The input is a single-shot query with no follow-up, or the interaction is an inline rewrite on a selection. Both want a field shaped like a search box or a small popover, and neither benefits from an attachment rail and a model switcher.

Trade-offs

A larger field makes writing easier but leaves less room for answers. Enter-to-send is quick for short messages and can send a draft by accident. Extra controls reduce writing space and add choices before people ask a question.

Checklist

  • What’s the maximum height, and what does the conversation look like when the composer is at it?
  • What does Enter do, is that discoverable, and can it be changed?
  • What happens when someone pastes four thousand words?
  • Does focus return to the input after send?
  • Does a half-written draft survive navigating away and back?
  • Is the whole conversation area a drop target, or only the box?
  • How many controls are in the row, and what’s the rule for adding another?
  • Does the composer stay usable while a response streams?
  • Is the send control reachable and labelled for a screen reader, including in its stop state?
  • On a phone, how much of the screen is left for the answer once the keyboard is up?

Compare

ChatGPT keeps the composer live during generation so a follow-up can be drafted while the answer arrives, which treats the box as a workspace rather than a turnstile. Claude turns a large paste into an attachment instead of a wall of text, so the transcript keeps its shape when someone drops in a document. Perplexity biases the whole composer toward a single query with source and focus controls beside it, closer to a search field than a message box. Raycast removes the persistent composer entirely and makes invocation a keystroke into a command bar. That’s HAX guideline 7 taken to its conclusion, and it costs the running history a pinned composer gets for free.

Attachment tray displays added files. Command menu opens actions and item pickers from the field. Stop generation can share the Send button’s position while an answer arrives.

Message turn displays the sent message. Model picker selects the model used to answer it.

Composer anatomy An input that grows with its content up to a ceiling then scrolls, with a finite control row inside it, a model picker and a microphone beside the send button, a send button that becomes stop during generation, and a note that a large paste becomes an attachment rather than a wall of text. Grows to a ceiling, then stops the conversation, still readable + model send max height, then it scrolls inside 1 2 3 4 1 THE CEILING Unbounded growth pushes the answer off the screen while it is being replied to. 2 WHAT ENTER DOES Send or newline. No right answer, and an accidental send burns a real generation. 3 A FINITE CONTROL ROW Every feature team wants a sixth icon. Each one costs input width directly. 4 THE SEND SLOT Becomes stop while a response streams, which is where the pointer already is. Drafts should survive navigation, and the drop target is the whole conversation, not the box.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

An input that grows with its content to a ceiling and then scrolls, with a finite control row inside it. The send button becomes stop during generation, so the thing that starts a response is also the thing that halts it.

shadcn
npx shadcn@latest add textarea button
npm
lucide-react
Tokens
--background--foreground--primary--primary-foreground--accent--accent-foreground--muted-foreground--border

Composer.tsxGrows to a ceiling, Enter sends, send becomes stop, a big paste becomes an attachment, the draft survives navigation.

import { useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent, type KeyboardEvent } from "react";
import { ChevronDown, Mic, Square, ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";

/**
 * Grows with its content to a ceiling, then scrolls inside. Unbounded growth
 * pushes the answer off the screen while it is being replied to, which is the
 * one thing a composer must not do.
 */
const MAX_HEIGHT = 160;

/**
 * Above this, a paste stops being text and becomes an attachment. A wall of
 * pasted text in the box is unreadable and unscrollable at the ceiling, and it
 * is almost always a document rather than a message.
 */
const PASTE_AS_ATTACHMENT = 2000;

type Props = {
  /** Keys the draft in sessionStorage, so navigating away and back keeps it. */
  draftKey: string;
  /** What the box holds when there is no saved draft. */
  defaultDraft?: string;
  /** True while a response streams. The send slot becomes stop. */
  streaming: boolean;
  onSend: (text: string) => void;
  onStop: () => void;
  onAttach: (file: File | string) => void;
  model: string;
  onPickModel: () => void;
  onVoice: () => void;
};

export function Composer({
  draftKey, defaultDraft = "", streaming, onSend, onStop, onAttach, model, onPickModel, onVoice,
}: Props) {
  // Guarded, because this also renders on the server, where there is no storage.
  const [draft, setDraft] = useState(
    () => (typeof window === "undefined" ? null : sessionStorage.getItem(draftKey)) ?? defaultDraft,
  );
  const ref = useRef<HTMLTextAreaElement>(null);
  const fileRef = useRef<HTMLInputElement>(null);

  useEffect(() => { sessionStorage.setItem(draftKey, draft); }, [draftKey, draft]);

  // Measure, then set. Resetting to auto first lets the box shrink when a line
  // is deleted; without it scrollHeight only ever reports the larger size.
  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    el.style.height = "auto";
    el.style.height = `${Math.min(el.scrollHeight, MAX_HEIGHT)}px`;
  }, [draft]);

  const send = () => {
    const text = draft.trim();
    if (!text || streaming) return;
    onSend(text);
    setDraft("");
  };

  // Enter sends, Shift+Enter breaks the line. There is no right answer here,
  // but an accidental send burns a real generation, so the modifier guards the
  // cheaper mistake: a newline you did not want costs a keystroke to fix.
  const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
      e.preventDefault();
      send();
    }
  };

  const onPaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {
    const file = e.clipboardData.files[0];
    const text = e.clipboardData.getData("text/plain");
    if (file) { e.preventDefault(); onAttach(file); return; }
    if (text.length > PASTE_AS_ATTACHMENT) { e.preventDefault(); onAttach(text); }
  };

  return (
    <div className="rounded-lg border bg-background">
      <Textarea
        ref={ref}
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        onKeyDown={onKeyDown}
        onPaste={onPaste}
        rows={1}
        placeholder="Message"
        aria-label="Message"
        className="min-h-0 resize-none overflow-y-auto border-0 px-4 pt-3 shadow-none focus-visible:ring-0"
      />

      {/* A finite row. Every feature team wants a sixth control here, and each
          one costs input width directly. Three, and the send slot. */}
      <div className="flex items-center gap-1.5 border-t px-2 py-2">
        <input ref={fileRef} type="file" hidden onChange={(e) => e.target.files?.[0] && onAttach(e.target.files[0])} />
        <Button variant="ghost" size="sm" onClick={() => fileRef.current?.click()} aria-label="Attach a file">+</Button>
        <Button variant="ghost" size="sm" className="ml-auto" onClick={onPickModel}>
          {model} <ChevronDown className="size-4" />
        </Button>
        <Button variant="ghost" size="sm" onClick={onVoice} aria-label="Dictate">
          <Mic className="size-4" />
        </Button>

        {/* One slot, two jobs. The pointer is already here when the response
            starts, so stop lives where send was. */}
        {streaming ? (
          <Button size="sm" variant="outline" onClick={onStop}>
            <Square className="size-3 fill-current" /> stop
          </Button>
        ) : (
          <Button size="sm" onClick={send} disabled={!draft.trim()}>
            <ArrowUp className="size-4" /> send
          </Button>
        )}
      </div>
    </div>
  );
}

demo.tsxHow it is called. Send fakes a few seconds of streaming so the slot shows stop.

import { useState } from "react";
import { Composer } from "./Composer";

/**
 * A draft long enough to hit the ceiling, so the scroll inside is visible.
 * Send pretends to stream for a few seconds, which is when the slot shows
 * stop; stop ends it early.
 */
export default function Demo() {
  const [streaming, setStreaming] = useState(false);
  const [timer, setTimer] = useState<ReturnType<typeof setTimeout>>();

  const send = () => {
    setStreaming(true);
    setTimer(setTimeout(() => setStreaming(false), 4000));
  };
  const stop = () => {
    clearTimeout(timer);
    setStreaming(false);
  };

  return (
    <Composer
      draftKey="demo:composer"
      defaultDraft={
        "Go through the lease and pull out every clause about early termination, notice periods and what happens to the deposit. Compare each one against the 2019 version, which is the scan I attached, and tell me what changed.\n\nThen check the figures spreadsheet: the monthly totals on the summary tab should match the sum of the line items on the detail tab, and I think two of them don't.\n\nKeep it short. Bullet points I can paste straight into the deck, one line per finding, and say which page each one came from."
      }
      streaming={streaming}
      onSend={send}
      onStop={stop}
      onAttach={() => {}}
      model="model"
      onPickModel={() => {}}
      onVoice={() => {}}
    />
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No screenshots yet for this pattern. Browse Products to see other examples.