Skip to content
KONIGI

Composer

One box has to take a word, a page, a file and a keyboard shortcut without growing into the whole screen.

Updated September 12, 2026

Problem

The same input handles “why” and a two-thousand-word brief pasted from a doc. It has to accept files, expose a model choice, a mode toggle and a send button, stay reachable from the keyboard, and take up as little of the screen as possible so the answer above it stays readable.

Solution

Start with a textarea that grows with its content up to a ceiling, then scrolls internally. The ceiling is the part people forget. Unbounded growth means a long prompt pushes the conversation off the top of the screen, so the viewer loses the thing they’re responding to at exactly the moment they’re responding to it. Somewhere around a third of the viewport is a reasonable cap.

Then decide what Enter does, which is the most contested question in the pattern and has no correct answer. Enter-to-send suits short conversational turns and makes the product feel fast. Enter-for-newline suits people who write structured prompts with paragraphs and lists. Both populations use the same product. Most ship Enter-to-send with Shift+Enter for newline, then add a preference once enough people have accidentally fired off half a thought. An accidental send burns a generation and leaves an incomplete question in the permanent transcript for the model to answer.

Paste deserves special handling. Someone dropping four thousand words in to be summarised produces a composer that fills the screen and a user turn that dwarfs every answer. Converting a large paste into an attachment keeps the input compact and makes the content addressable.

The rest is unglamorous and load-bearing:

  • Focus returns after send. Losing it means every follow-up costs a click.
  • Drafts survive navigation. Clicking a conversation in the sidebar and coming back to an empty box destroys real work, and it is a trivial thing to persist.
  • The drop target is the whole conversation area, not the small box. Nobody aims a dragged file at a 40-pixel strip.
  • The control row inside the composer is finite. Attach, model, mode, voice, send. Every feature team wants a sixth icon. Hold the line, because the composer is the one surface where a crowded row directly costs input space.

HAX guideline 7 asks for efficient invocation: make the assistant cheap to summon and cheap to use. A composer that’s always present, always focused, and always the same size is the strongest form of that.

Use when

Any interface with repeated free-text turns and a visible history.

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

Every option surfaced in the composer costs input width and adds a decision before the viewer has typed anything. Auto-growth trades a comfortable writing area against the readable conversation above it, and the two can’t both win. Enter-to-send is fast and destroys unfinished thoughts; Enter-for-newline is safe and makes the product feel sluggish to everyone writing one-liners. A sticky composer pinned to the bottom is the standard answer and eats vertical space on short viewports, where a long answer has least room to begin with.

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 is what appears above the composer once a file is added. Command menu lives inside it and is triggered from it. Stop generation borrows its send slot mid-response. Message turn is what the composer produces. Model picker is the control most often crammed into the same row.

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 captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.