Skip to main content
Saved
Pattern
Difficulty Intermediate

Prompt Input

An auto-growing message composer with Enter-to-send, Shift+Enter for newlines, and a busy state that swaps send for stop.

Den Odell
By Den Odell Added

Prompt Input

Problem

Someone drops a single-line <input> into their chat UI, and immediately three things are wrong. The user can’t write a second paragraph without the text scrolling out of view. They hit Enter to add a line break and accidentally send a half-written message. And when they’re typing Japanese or Chinese, pressing Enter to confirm the character selection fires off the prompt instead.

Then the model starts replying and the send button just… sits there, enabled, inviting the user to fire a second prompt into a stream that’s still producing the first answer.

A prompt composer looks trivial, just a box and a button, but it’s the control the user touches most, and every rough edge compounds. The details that separate a good one from a frustrating one are all in the keyboard and state handling, which is exactly the stuff that’s easy to skip.

Solution

Build the composer on a <textarea> so multi-line input works, and auto-grow it to fit its content up to a cap, after which it scrolls. Handle the keyboard deliberately: Enter sends, Shift+Enter (or Ctrl/Cmd+Enter, pick one and be consistent) inserts a newline. Crucially, ignore Enter while an IME composition is active (check event.isComposing or event.keyCode === 229), or you’ll send messages out from under users typing in many languages.

Tie the button to the generation state. When idle it’s a disabled-until-non-empty send button; while a response streams it becomes a Stop Generation button. Disable submission during generation, but never disable the stop path. Trim whitespace before sending and refuse empty or whitespace-only prompts. On send, clear the field and reset its height so the next message starts fresh.

Example

The core composer with auto-grow and correct keyboard handling across frameworks, then submit-state wiring, IME safety, and progressive enhancement.

The Composer

function PromptInput({ onSend, busy }) {
  const [value, setValue] = useState('');
  const ref = useRef(null);

  const grow = (el) => {
    el.style.height = 'auto';
    el.style.height = Math.min(el.scrollHeight, 200) + 'px';
  };

  const submit = () => {
    const text = value.trim();
    if (!text || busy) return;
    onSend(text);
    setValue('');
    ref.current.style.height = 'auto';
  };

  const onKeyDown = (e) => {
    // Enter sends; Shift+Enter is a newline; ignore Enter mid-IME-composition
    if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
      e.preventDefault();
      submit();
    }
  };

  return (
    <textarea
      ref={ref}
      rows={1}
      value={value}
      placeholder="Message the assistant…"
      onChange={(e) => { setValue(e.target.value); grow(e.target); }}
      onKeyDown={onKeyDown}
    />
  );
}

Send or Stop, Never Both

The action button reflects the generation state rather than sitting statically beside the field.

function Composer({ busy, canSend, onSend, onStop }) {
  return (
    <form className="composer" onSubmit={(e) => { e.preventDefault(); onSend(); }}>
      <textarea /* …as above… */ />
      {busy ? (
        <button type="button" onClick={onStop} aria-label="Stop generating">
          <StopIcon /> Stop
        </button>
      ) : (
        <button type="submit" disabled={!canSend} aria-label="Send message">
          <SendIcon /> Send
        </button>
      )}
    </form>
  );
}

CSS-Only Auto-Grow

Newer browsers can size the textarea without any JavaScript, which sidesteps the manual height juggling entirely.

.composer textarea {
  field-sizing: content;   /* grows with content where supported */
  min-height: 2.5rem;
  max-height: 12rem;       /* then it scrolls */
  resize: none;
  overflow-y: auto;
}

Working Without JavaScript

Wrapping the composer in a real <form> that posts to an endpoint means a message can still be sent if the enhancement hasn’t loaded, then upgraded to streaming once it has.

<form action="/chat" method="post" class="composer">
  <textarea name="prompt" rows="1" required placeholder="Message the assistant…"></textarea>
  <button type="submit">Send</button>
</form>

Benefits

  • Multi-line prompts work naturally, so users can paste code or write structured questions without the field fighting them.
  • Enter-to-send with Shift+Enter for newlines matches the muscle memory users bring from every other chat app.
  • Respecting IME composition means users typing in Japanese, Chinese, Korean, and other languages don’t send messages by accident.
  • Swapping send for stop while busy makes the current state obvious and prevents prompts colliding with an in-flight response.
  • Auto-grow keeps the whole draft visible, and a <form> wrapper gives you a working baseline before JavaScript loads.

Tradeoffs

  • Keyboard behavior is a nest of edge cases (IME, autocomplete confirmation, mobile keyboards that send their own Enter), and getting them all right takes testing on real devices.
  • Enter-to-send surprises users who expect Enter to mean newline; the convention is now common but not universal, so the affordance needs to be discoverable.
  • JavaScript auto-grow forces a reflow on every keystroke; the CSS field-sizing approach is cleaner but not yet universally supported.
  • On small screens the growing textarea competes with the on-screen keyboard for vertical space and needs a sensible cap.
  • The busy/idle button swap adds state that has to stay in sync with the actual generation, or you get a stop button for a response that already finished.

Summary

A prompt composer is the most-touched control in an AI interface, and it earns its keep in the details: an auto-growing textarea, Enter-to-send with Shift+Enter for newlines, IME-safe key handling, whitespace trimming, and an action button that becomes a stop control while the model replies. Wrap it in a real form for a no-JavaScript baseline, and the everyday act of asking a question stops getting in the user’s way.

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.