Prompt Composer
SaveA 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-composerPre-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-runUsage
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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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 / maxLength | string / 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.tsxShowHide
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>
);
}