GodUIGodUI
81

Anatomy of the Prompt Composer

How one form stays one form across chips, an auto-growing textarea, a model picker, and a send button that morphs into stop — no remounts, no separate layouts.

Every "chat input" ends up needing the same five things: attachments, a field that grows with what you type, a model switcher, a drag target, and a send button that has to become a stop button mid-stream. PromptComposer builds all five as states of one <form>, not five components stitched together. Here's how each state moves.

One form, three stacked rows

The panel is a single <form data-slot="prompt-composer"> with three rows that are always present in the DOM in the same order — an attachment row that collapses to nothing when empty, the textarea, and a toolbar. Nothing here remounts when attachments, streaming, or the model list change; only which children exist inside each row changes.

Anatomyone form, three stacked rows
Chips
AnimatePresence height, collapses to 0 when empty
Field
textarea, auto-grows to maxRows then scrolls
Send
attach + model left, count + send right
tsx
 
const PANEL_BASE =
  "group/composer relative flex w-full flex-col gap-2 rounded-2xl border border-border bg-card/80 p-2.5 shadow-sm backdrop-blur-md transition-[box-shadow,border-color] focus-within:border-ring focus-within:shadow-md";

focus-within is doing the work a focus ring normally would — since the element that's actually focused is the <textarea> two levels down, not the <form> itself. The panel's border and shadow answer to whatever's focused anywhere inside it, which is why clicking the attach button or the model picker doesn't dim the "focused" look the way blurring the textarea would.

Chips spring in, spring out

Attachments aren't rendered directly inside the panel — they're wrapped in two nested AnimatePresence regions. The outer one owns the row's own mount/unmount (height: 0 → "auto", so the panel never jumps when the first file lands); the inner one owns each chip's own spring.

The motionspring 520/32 · looped
Wrapper
outer AnimatePresence, height 0 → auto + opacity
Chip
scale 0.8 → 1 spring (520/32) + opacity, layout on exit
tsx
 
<AnimatePresence initial={false}>
  {chips.length > 0 ? (
    <motion.div
      initial={{ opacity: 0, height: 0 }}
      animate={{ opacity: 1, height: "auto" }}
      exit={{ opacity: 0, height: 0 }}
      className="flex flex-wrap gap-1.5 overflow-hidden px-1"
    >
      <AnimatePresence initial={false}>
        {chips.map((chip) => (
          <motion.span
            key={chip.id}
            layout
            initial={{ opacity: 0, scale: 0.8 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.8 }}
            transition={{ type: "spring", stiffness: 520, damping: 32 }}
          >
            {/* icon, name, size, remove button */}
          </motion.span>
        ))}
      </AnimatePresence>
    </motion.div>
  ) : null}
</AnimatePresence>

layout on each chip is what makes removing one feel physical instead of abrupt — Framer measures the chip row before and after a chip leaves and animates the survivors into their new positions on the same spring, rather than snapping them sideways the instant the DOM updates. 520/32 is a stiff, lightly-damped spring — fast enough that a chip feels like it lands, not like it fades up.

A textarea that grows without a ResizeObserver

There's no controlled "rows" state and no ResizeObserver. Every time the text changes, a useLayoutEffect resets the height to auto, reads the resulting scrollHeight, then clamps that against maxRows * 24:

tsx
 
React.useLayoutEffect(() => {
  const el = textareaRef.current;
  if (!el) return;
  el.style.height = "auto";
  const lineHeight = 24;
  const max = lineHeight * maxRows;
  el.style.height = `${Math.min(el.scrollHeight, max)}px`;
  el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
}, [text, maxRows]);

Resetting to auto first matters — without it, scrollHeight on a textarea that's already been sized would just report the size you set it to, not what the content actually needs, so a delete would never shrink the field back down. useLayoutEffect (not useEffect) runs this synchronously before the browser paints, so there's never a frame where the field is the wrong height. Past maxRows, overflowY flips to auto and growth stops — the field scrolls instead of pushing the toolbar off-panel.

Send becomes stop, not a different button

Streaming doesn't swap in a different <button> — the same element stays mounted (same size, same position, same type toggling under the hood) and only its icon cross-fades via AnimatePresence mode="wait":

Send ↔ Stopmode="wait" · looped
Send
key="send" · scale 1 → 0.6 out, 0.15s
Stop
key="stop" · scale 0.6 → 1 in, 0.15s, after send exits
tsx
 
<AnimatePresence mode="wait" initial={false}>
  {isStreaming ? (
    <motion.span
      key="stop"
      initial={{ opacity: 0, scale: 0.6 }}
      animate={{ opacity: 1, scale: 1 }}
      exit={{ opacity: 0, scale: 0.6 }}
      transition={{ duration: 0.15 }}
    >
      <StopIcon />
    </motion.span>
  ) : (
    <motion.span
      key="send"
      initial={{ opacity: 0, scale: 0.6 }}
      animate={{ opacity: 1, scale: 1 }}
      exit={{ opacity: 0, scale: 0.6 }}
      transition={{ duration: 0.15 }}
    >
      <ArrowUpIcon />
    </motion.span>
  )}
</AnimatePresence>

mode="wait" is the detail that makes this read as one icon morphing instead of two icons crossing paths: Framer waits for the exiting key to finish its exit animation before mounting the next one, so there's a beat where the button is empty rather than a moment where an arrow and a stop square overlap mid-scale. At 0.15s each, that gap is imperceptible in real use — slowed down in the scene above so the sequencing is visible.

The model picker pops from the bottom

The model list lives in a popover anchored to its trigger with absolute bottom-full, so it opens upward — into the space above the composer, never pushing the toolbar down. Its spring's initial/exit both push y and scale from the bottom edge:

tsx
 
<motion.ul
  initial={{ opacity: 0, y: 6, scale: 0.96 }}
  animate={{ opacity: 1, y: 0, scale: 1 }}
  exit={{ opacity: 0, y: 6, scale: 0.96 }}
  transition={{ type: "spring", stiffness: 320, damping: 32, mass: 0.9 }}
  className="absolute bottom-full left-0 z-popover mb-1.5 origin-bottom rounded-xl …"
/>

origin-bottom matters as much as the y offset — without it, the scale: 0.96 → 1 would grow from the popover's own center, so it'd visibly drift toward the trigger as it appears. Pinning the transform origin to the bottom edge means it only ever grows away from where you clicked, which is what makes it feel anchored rather than floating in from nowhere.

Dropping a file anywhere on the panel

Drag state isn't scoped to a drop zone inside the field — it's tracked on the <form> itself, so the whole panel is the target:

tsx
 
className={`${PANEL_BASE} ${dragging ? "border-ring ring-2 ring-ring/40" : ""}`}
onDragOver={(e) => {
  e.preventDefault();
  if (!dragging) setDragging(true);
}}
onDragLeave={(e) => {
  if (e.currentTarget.contains(e.relatedTarget as Node)) return;
  setDragging(false);
}}
onDrop={(e) => {
  e.preventDefault();
  setDragging(false);
  if (e.dataTransfer.files.length) {
    setChips([...chips, ...filesToAttachments(e.dataTransfer.files)]);
  }
}}

The onDragLeave guard — checking whether relatedTarget is still inside currentTarget — is what keeps the ring from flickering as the cursor crosses internal borders (the textarea, the toolbar) while dragging over the panel. Without it, every child boundary would fire a spurious drag-leave and the highlight would strobe on its way to the drop target. The same filesToAttachments helper feeds this, a paste handler, and the hidden file <input> — three entry points, one path into the chip row.

Keyboard and streaming state

⌘/Ctrl + Enter submits from inside the textarea without needing a dedicated form-submit shortcut:

tsx
 
onKeyDown={(e) => {
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
    e.preventDefault();
    submit();
  }
}}

submit() itself branches on isStreaming before anything else — while streaming, the button's type is "button" and its onClick calls onStop, so pressing Enter or clicking send can never fire a second onSend while a response is still in flight. The button's aria-label tracks the same branch ("Send message" / "Stop generating"), so a screen reader announces the swap the icon morph is showing sighted users.

The result

Resultthe real component — type, attach, send

⌘/Ctrl + Enter to send · drop or paste a file to attach it

One <form>, three rows that never remount, and every "different state" — chips present, streaming, a model open, a file mid-drag — expressed as props on the same tree instead of a different component underneath it.

On this page

Built with GodUI

Beautifully crafted motion components for modern interfaces.

Star on GitHub