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}
/>
);
} <script setup>
import { ref } from 'vue';
const props = defineProps({ busy: Boolean });
const emit = defineEmits(['send']);
const value = ref('');
const el = ref(null);
function grow() {
el.value.style.height = 'auto';
el.value.style.height = Math.min(el.value.scrollHeight, 200) + 'px';
}
function submit() {
const text = value.value.trim();
if (!text || props.busy) return;
emit('send', text);
value.value = '';
el.value.style.height = 'auto';
}
function onKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
submit();
}
}
</script>
<template>
<textarea
ref="el"
rows="1"
v-model="value"
placeholder="Message the assistant…"
@input="grow"
@keydown="onKeyDown"
/>
</template> <script>
export let busy = false;
let value = '';
let el;
function grow() {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}
function submit() {
const text = value.trim();
if (!text || busy) return;
dispatch('send', text);
value = '';
el.style.height = 'auto';
}
function onKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
submit();
}
}
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
</script>
<textarea
bind:this={el}
bind:value
rows="1"
placeholder="Message the assistant…"
on:input={grow}
on:keydown={onKeyDown}
/> class PromptInput extends HTMLElement {
connectedCallback() {
this.innerHTML = `<textarea rows="1" placeholder="Message the assistant…"></textarea>`;
const el = this.querySelector('textarea');
el.addEventListener('input', () => {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
});
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
const text = el.value.trim();
if (!text || this.hasAttribute('busy')) return;
this.dispatchEvent(new CustomEvent('send', { detail: text }));
el.value = '';
el.style.height = 'auto';
}
});
}
}
customElements.define('prompt-input', PromptInput); 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-sizingapproach 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.