Skip to content
Seamless UI

Prompt Composer

Save

A prompt box that grows with its text: Enter sends, Shift+Enter adds a line, and Stop replaces Send during a run.

Category
Agent
Version
0.2.0
Frameworks
Next.js, Vite + React
Styling
Tailwind CSS v4

Preview

Prompt Composer preview

Installation

npx @seamlessui/cli add prompt-composer

Pre-release@seamlessui/cli is not on npm yet. Install from a local build.

Review the plan first. A dry run lists every file and package without changing anything:

npx @seamlessui/cli add prompt-composer --dry-run

Usage

prompt-composer-example.tsx
"use client";

import { useState } from "react";
import { type ComposerStatus, PromptComposer } from "@/components/seamless/agent/prompt-composer";

export default function PromptComposerExample() {
  const [status, setStatus] = useState<ComposerStatus>("idle");
  const [last, setLast] = useState<string | null>(null);

  async function send(prompt: string) {
    setStatus("submitting");
    await new Promise((resolve) => setTimeout(resolve, 500)); // your API call
    setLast(prompt);
    setStatus("running");
  }

  return (
    <div className="grid w-full max-w-lg gap-2">
      <PromptComposer onSubmit={send} onStop={() => setStatus("idle")} status={status} />
      {last ? <p className="text-xs text-zinc-500">Sent: {last}</p> : null}
    </div>
  );
}

API

PropTypeDefaultDescription
onSubmit(prompt: string) => void | Promise<void>—Receives the trimmed prompt; the field clears when it resolves.
onStop() => void—Shown as a Stop button while status is running.
status"idle" | "submitting" | "running""idle"Disables sending while busy.
placeholder / label / maxLengthstring / string / number—Field text, accessible label, and character limit (a counter appears near the limit).

Accessibility

  • The field has an accessible label and a hint describing Enter and Shift+Enter; while running, the hint states that the agent is working.
  • Enter does not send while an input method is composing text.
  • Send shows a busy state while submitting; Stop replaces it during a run.

Files

Installed into src/components/seamless, or components/seamless when the project has no src directory.

  • src/components/seamless/agent/prompt-composer.tsxprompt-composer
  • Also installs button@0.2.0registry dependency
  • npm packages: noneReact and Tailwind CSS v4 required

Source

The exact source the CLI installs, from /r/items/react/prompt-composer/0.2.0.json.

agent/prompt-composer.tsxShow
agent/prompt-composer.tsx
"use client";

import { type FormEvent, type KeyboardEvent, useId, useState } from "react";
import { Button } from "../ui/button";

export type ComposerStatus = "idle" | "submitting" | "running";

export interface PromptComposerProps {
  /** Called with the trimmed prompt. The field clears when it resolves. */
  onSubmit: (prompt: string) => void | Promise<void>;
  /** Shown as a Stop button while the agent is running. */
  onStop?: () => void;
  status?: ComposerStatus;
  placeholder?: string;
  /** Accessible label for the field. */
  label?: string;
  maxLength?: number;
  defaultValue?: string;
  className?: string;
}

const ease = "ease-[cubic-bezier(0.23,1,0.32,1)]";

/** A prompt box: Enter sends, Shift+Enter adds a line, and Stop replaces Send while a run is active. */
export function PromptComposer({
  onSubmit,
  onStop,
  status = "idle",
  placeholder = "Ask the agent to do something…",
  label = "Message the agent",
  maxLength = 4000,
  defaultValue = "",
  className,
}: PromptComposerProps) {
  const [value, setValue] = useState(defaultValue);
  const id = useId();
  const busy = status !== "idle";
  const canSend = value.trim().length > 0 && !busy;
  const nearLimit = value.length > maxLength * 0.9;

  async function submit(event?: FormEvent) {
    event?.preventDefault();
    if (!canSend) return;
    const prompt = value.trim();
    await onSubmit(prompt);
    setValue("");
  }

  function onKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
    // Ignore Enter while an input method is composing text (e.g. Japanese, Chinese).
    if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
      event.preventDefault();
      void submit();
    }
  }

  return (
    <form
      onSubmit={submit}
      data-status={status}
      className={[
        "rounded-xl border border-zinc-950/10 bg-white shadow-[0_1px_2px_rgb(0_0_0/0.04)]",
        "transition-[border-color,box-shadow] duration-150 ease-out motion-reduce:transition-none",
        "hover:border-zinc-950/15 focus-within:border-zinc-950/25 focus-within:shadow-[0_0_0_3px_rgb(14_165_233/0.16)]",
        "dark:border-white/10 dark:bg-black dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06)] dark:hover:border-white/20",
        "dark:focus-within:border-white/30 dark:focus-within:shadow-[0_0_0_3px_rgb(56_189_248/0.18),inset_0_1px_0_rgb(255_255_255/0.06)]",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
    >
      <label htmlFor={id} className="sr-only">
        {label}
      </label>
      <textarea
        id={id}
        value={value}
        onChange={(event) => setValue(event.target.value)}
        onKeyDown={onKeyDown}
        placeholder={placeholder}
        maxLength={maxLength}
        rows={1}
        aria-describedby={`${id}-hint`}
        className="block max-h-48 min-h-11 w-full resize-none bg-transparent px-3.5 pt-3 pb-1 text-sm leading-relaxed text-zinc-950 [field-sizing:content] placeholder:text-zinc-500 focus:outline-none dark:text-white dark:placeholder:text-zinc-500"
      />
      <div className="flex min-h-11 items-center justify-between gap-3 py-2 pr-2 pl-3.5">
        <p id={`${id}-hint`} className="flex min-w-0 items-center gap-2 text-xs text-zinc-500">
          {status === "running" ? (
            <span key="running" className={`flex items-center gap-2 text-sky-700 transition-opacity duration-200 ${ease} starting:opacity-0 motion-reduce:transition-none dark:text-sky-400`}>
              <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="size-3.5 shrink-0">
                <circle cx="8" cy="8" r="6.25" strokeOpacity="0.2" />
                <path d="M8 1.75A6.25 6.25 0 1 1 1.75 8" className="origin-center motion-safe:animate-spin" />
              </svg>
              Agent is working
            </span>
          ) : (
            <span key="hint" className="hidden items-center gap-1 sm:flex">
              <Key>Enter</Key> to send, <Key>Shift</Key>
              <span aria-hidden="true">+</span>
              <Key>Enter</Key> for a new line
            </span>
          )}
          {nearLimit ? (
            <span className="font-mono tabular-nums text-amber-700 dark:text-amber-400">{maxLength - value.length} characters left</span>
          ) : null}
        </p>
        {status === "running" && onStop ? (
          <Button type="button" size="sm" variant="secondary" onClick={onStop}>
            <svg aria-hidden="true" viewBox="0 0 16 16" fill="currentColor">
              <rect x="4.5" y="4.5" width="7" height="7" rx="1.5" />
            </svg>
            Stop
          </Button>
        ) : (
          <Button type="submit" size="sm" loading={status === "submitting"} disabled={!canSend && status !== "submitting"}>
            Send
          </Button>
        )}
      </div>
    </form>
  );
}

function Key({ children }: { children: string }) {
  return (
    <kbd className="rounded-[5px] border border-zinc-950/10 px-1 font-sans text-[11px] leading-4 text-zinc-600 dark:border-white/15 dark:text-zinc-400">
      {children}
    </kbd>
  );
}