Prompt Input
A chat composer that grows with its content, submits on Enter, and crossfades its send button into a stop button while a reply streams.
"use client";
import { useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { MessageBubble } from "@/components/motion/message-bubble";
import { PromptInput } from "@/components/motion/prompt-input";
import { StreamingResponse } from "@/components/motion/streaming-response";
import { StreamingText } from "@/components/motion/streaming-text";
const REPLY = "Got it, I'll take a look and follow up here shortly.";
/** Characters per second for the simulated reply. */
const SPEED = 60;
type Phase = "idle" | "streaming" | "done";
export function PromptInputPreview() {
const reduce = useReducedMotion();
const [prompt, setPrompt] = useState<string | null>(null);
const [phase, setPhase] = useState<Phase>("idle");
const [count, setCount] = useState(0);
useEffect(() => {
if (phase !== "streaming") return;
if (reduce) {
setCount(REPLY.length);
setPhase("done");
return;
}
setCount(0);
const startedAt = performance.now();
let frame: number;
const tick = (now: number) => {
const next = Math.min(REPLY.length, Math.floor(((now - startedAt) / 1000) * SPEED));
setCount(next);
if (next < REPLY.length) frame = requestAnimationFrame(tick);
else setPhase("done");
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [phase, reduce]);
return (
<div className="flex w-full max-w-sm flex-col gap-3">
{prompt ? (
<MessageBubble align="end" tone="accent">
{prompt}
</MessageBubble>
) : null}
{phase !== "idle" ? (
<StreamingResponse
status={phase === "streaming" ? "streaming" : "complete"}
copyText={REPLY}
onRetry={() => setPhase("streaming")}
showFeedback
>
<StreamingText text={REPLY.slice(0, count)} streaming={phase === "streaming"} />
</StreamingResponse>
) : null}
<PromptInput
placeholder="Ask anything..."
loading={phase === "streaming"}
onStop={() => setPhase("done")}
onSubmit={(value) => {
setPrompt(value);
setPhase("streaming");
}}
/>
</div>
);
}
"use client";
// easeui.dev/components/agents/prompt-input
import { ArrowUp, Square } from "lucide-react";
import { type KeyboardEvent, type TextareaHTMLAttributes, useState } from "react";
import { cn } from "@/lib/utils";
export interface PromptInputProps
extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "defaultValue" | "onChange" | "onSubmit"> {
/** Controlled value. */
value?: string;
/** Starting value when uncontrolled. Default "". */
defaultValue?: string;
onChange?: (value: string) => void;
/** Called with the trimmed text on Enter or the send button. */
onSubmit: (value: string) => void;
/** Swaps the send button for a stop button. Default false. */
loading?: boolean;
/** Called from the stop button while loading. */
onStop?: () => void;
className?: string;
}
// Same crossfade CopyButton uses, so the button never changes size.
const SWAP = "col-start-1 row-start-1 transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none";
const SHOWN = "scale-100 opacity-100";
const HIDDEN = "scale-50 opacity-0";
/**
* A chat composer: a textarea that grows with its content up to a scrollable
* cap, and a send button beside it. Enter submits, Shift+Enter starts a new
* line, and the button crossfades into a stop button while a reply streams.
*/
export function PromptInput({
value,
defaultValue = "",
onChange,
onSubmit,
loading = false,
onStop,
disabled = false,
placeholder = "Message...",
className,
...props
}: PromptInputProps) {
const [uncontrolled, setUncontrolled] = useState(defaultValue);
const isControlled = value !== undefined;
const text = isControlled ? value : uncontrolled;
const setValue = (next: string) => {
if (!isControlled) setUncontrolled(next);
onChange?.(next);
};
const submit = () => {
const trimmed = text.trim();
if (!trimmed || loading || disabled) return;
onSubmit(trimmed);
if (!isControlled) setUncontrolled("");
};
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey && !loading && !disabled) {
event.preventDefault();
submit();
}
};
return (
<div
className={cn(
"flex items-end gap-2 rounded-2xl bg-card p-2 pl-3.5 shadow-[0_0_0_1px_var(--border-strong)] outline-none transition-shadow duration-150 ease-out",
"focus-within:shadow-[0_0_0_2px_var(--accent)]",
className,
)}
>
<textarea
rows={1}
disabled={disabled}
placeholder={placeholder}
value={text}
onChange={(event) => setValue(event.target.value)}
onKeyDown={onKeyDown}
className={cn(
"max-h-48 flex-1 resize-none bg-transparent py-1.5 text-base text-foreground outline-none sm:text-sm",
"[field-sizing:content]",
"placeholder:text-muted-foreground",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
{...props}
/>
<button
type="button"
aria-label={loading ? "Stop" : "Send"}
disabled={loading ? false : disabled || !text.trim()}
onClick={() => (loading ? onStop?.() : submit())}
className={cn(
"relative inline-flex h-9 w-9 shrink-0 touch-manipulation items-center justify-center rounded-full bg-foreground text-background outline-none",
"transition-[background-color,opacity] duration-150 ease-out hover:bg-foreground/90",
"focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-40",
)}
>
<span aria-hidden="true" className="grid place-items-center">
<ArrowUp className={cn(SWAP, "h-4 w-4", loading ? HIDDEN : SHOWN)} />
<Square className={cn(SWAP, "h-3 w-3 fill-current", loading ? SHOWN : HIDDEN)} />
</span>
</button>
</div>
);
}
Installation
$ bunx --bun shadcn add @easeui/prompt-input
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react motion tailwind-merge - 3
Add the source files
components/motion/prompt-input.tsx "use client"; // easeui.dev/components/agents/prompt-input import { ArrowUp, Square } from "lucide-react"; import { type KeyboardEvent, type TextareaHTMLAttributes, useState } from "react"; import { cn } from "@/lib/utils"; export interface PromptInputProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "defaultValue" | "onChange" | "onSubmit"> { /** Controlled value. */ value?: string; /** Starting value when uncontrolled. Default "". */ defaultValue?: string; onChange?: (value: string) => void; /** Called with the trimmed text on Enter or the send button. */ onSubmit: (value: string) => void; /** Swaps the send button for a stop button. Default false. */ loading?: boolean; /** Called from the stop button while loading. */ onStop?: () => void; className?: string; } // Same crossfade CopyButton uses, so the button never changes size. const SWAP = "col-start-1 row-start-1 transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none"; const SHOWN = "scale-100 opacity-100"; const HIDDEN = "scale-50 opacity-0"; /** * A chat composer: a textarea that grows with its content up to a scrollable * cap, and a send button beside it. Enter submits, Shift+Enter starts a new * line, and the button crossfades into a stop button while a reply streams. */ export function PromptInput({ value, defaultValue = "", onChange, onSubmit, loading = false, onStop, disabled = false, placeholder = "Message...", className, ...props }: PromptInputProps) { const [uncontrolled, setUncontrolled] = useState(defaultValue); const isControlled = value !== undefined; const text = isControlled ? value : uncontrolled; const setValue = (next: string) => { if (!isControlled) setUncontrolled(next); onChange?.(next); }; const submit = () => { const trimmed = text.trim(); if (!trimmed || loading || disabled) return; onSubmit(trimmed); if (!isControlled) setUncontrolled(""); }; const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => { if (event.key === "Enter" && !event.shiftKey && !loading && !disabled) { event.preventDefault(); submit(); } }; return ( <div className={cn( "flex items-end gap-2 rounded-2xl bg-card p-2 pl-3.5 shadow-[0_0_0_1px_var(--border-strong)] outline-none transition-shadow duration-150 ease-out", "focus-within:shadow-[0_0_0_2px_var(--accent)]", className, )} > <textarea rows={1} disabled={disabled} placeholder={placeholder} value={text} onChange={(event) => setValue(event.target.value)} onKeyDown={onKeyDown} className={cn( "max-h-48 flex-1 resize-none bg-transparent py-1.5 text-base text-foreground outline-none sm:text-sm", "[field-sizing:content]", "placeholder:text-muted-foreground", "disabled:cursor-not-allowed disabled:opacity-50", )} {...props} /> <button type="button" aria-label={loading ? "Stop" : "Send"} disabled={loading ? false : disabled || !text.trim()} onClick={() => (loading ? onStop?.() : submit())} className={cn( "relative inline-flex h-9 w-9 shrink-0 touch-manipulation items-center justify-center rounded-full bg-foreground text-background outline-none", "transition-[background-color,opacity] duration-150 ease-out hover:bg-foreground/90", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:pointer-events-none disabled:opacity-40", )} > <span aria-hidden="true" className="grid place-items-center"> <ArrowUp className={cn(SWAP, "h-4 w-4", loading ? HIDDEN : SHOWN)} /> <Square className={cn(SWAP, "h-3 w-3 fill-current", loading ? SHOWN : HIDDEN)} /> </span> </button> </div> ); }lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }components/motion/message-bubble.tsx "use client"; import { motion, useReducedMotion } from "motion/react"; import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; export type MessageBubbleTone = "neutral" | "accent"; export type MessageBubbleAlign = "start" | "end"; export interface MessageBubbleProps { /** Which side the bubble sits on, matching who sent it. Default "start". */ align?: MessageBubbleAlign; /** Visual treatment. Default "neutral". */ tone?: MessageBubbleTone; children: ReactNode; className?: string; } const TONE_CLASS: Record<MessageBubbleTone, string> = { neutral: "bg-card text-foreground shadow-[0_0_0_1px_var(--border)]", accent: "bg-accent text-accent-foreground", }; const EASE = [0.23, 1, 0.32, 1] as const; /** A chat message surface with a speech-bubble tail that pops in from the side it belongs to. */ export function MessageBubble({ align = "start", tone = "neutral", children, className, }: MessageBubbleProps) { const reduce = useReducedMotion(); const fromX = align === "end" ? 10 : -10; return ( <motion.div initial={reduce ? { opacity: 0 } : { opacity: 0, x: fromX, scale: 0.97 }} animate={{ opacity: 1, x: 0, scale: 1 }} transition={{ duration: reduce ? 0.1 : 0.2, ease: EASE }} className={cn( "max-w-[85%] text-pretty rounded-2xl px-3.5 py-2.5 text-sm leading-6", align === "end" ? "ml-auto rounded-br-sm" : "mr-auto rounded-bl-sm", TONE_CLASS[tone], className, )} > {children} </motion.div> ); }components/motion/streaming-response.tsx "use client"; import { ArrowUpRight, RotateCcw, Share2, ThumbsDown, ThumbsUp } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { type ReactNode, useState } from "react"; import { CopyButton } from "@/components/motion/copy-button"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type StreamingResponseStatus = "streaming" | "complete" | "error"; export type StreamingResponseFeedback = "up" | "down" | null; export interface StreamingResponseProps { /** The response itself, such as a StreamingText or rendered markdown. Left unstyled: no card, no border. */ children: ReactNode; /** Default "streaming". The action row fades in once this leaves "streaming". */ status?: StreamingResponseStatus; /** Text the copy action writes to the clipboard. Omit to hide that action. */ copyText?: string; /** Shows a replay action. Omit to hide it. */ onRetry?: () => void; /** Shows a share action. Omit to hide it. */ onShare?: () => void; /** Shows a thumbs up / down toggle. Default false. */ showFeedback?: boolean; /** Controlled feedback value. */ feedback?: StreamingResponseFeedback; /** Starting feedback when uncontrolled. Default null. */ defaultFeedback?: StreamingResponseFeedback; onFeedbackChange?: (feedback: StreamingResponseFeedback) => void; /** Suggested next prompts, listed below the actions once settled. Omit to hide the list. */ followUps?: string[]; onFollowUp?: (text: string, index: number) => void; className?: string; } const ICON_BUTTON = cn( "relative inline-flex h-8 w-8 shrink-0 touch-manipulation items-center justify-center rounded-full text-muted-foreground outline-none after:absolute after:-inset-1.5", "transition-colors duration-150 ease-out hover:bg-muted hover:text-foreground", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", ); /** * Wraps a response with the actions people expect once it settles: copy, * replay, share, a thumbs up or down. No card or border, so the answer * reads as part of the page. Each action is opt-in: pass a handler to show it. */ export function StreamingResponse({ children, status = "streaming", copyText, onRetry, onShare, showFeedback = false, feedback, defaultFeedback = null, onFeedbackChange, followUps, onFollowUp, className, }: StreamingResponseProps) { const reduce = useReducedMotion(); const [uncontrolled, setUncontrolled] = useState(defaultFeedback); const isControlled = feedback !== undefined; const current = isControlled ? feedback : uncontrolled; const setFeedback = (next: StreamingResponseFeedback) => { if (!isControlled) setUncontrolled(next); onFeedbackChange?.(next); }; const settled = status !== "streaming"; const hasActions = settled && (copyText !== undefined || Boolean(onRetry) || Boolean(onShare) || showFeedback); return ( <div className={cn("flex flex-col gap-2", className)}> <div aria-busy={!settled}>{children}</div> {hasActions ? ( <motion.div initial={reduce ? false : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, ease: EASE_OUT }} className="-ml-2 flex items-center gap-0.5" > {copyText !== undefined ? ( <CopyButton value={copyText} className={cn(ICON_BUTTON, "bg-transparent shadow-none hover:bg-muted")} /> ) : null} {onRetry ? ( <button type="button" aria-label="Replay" onClick={onRetry} className={ICON_BUTTON}> <RotateCcw aria-hidden="true" className="h-4 w-4" /> </button> ) : null} {onShare ? ( <button type="button" aria-label="Share" onClick={onShare} className={ICON_BUTTON}> <Share2 aria-hidden="true" className="h-4 w-4" /> </button> ) : null} {showFeedback ? ( <> <button type="button" aria-label="Good response" aria-pressed={current === "up"} onClick={() => setFeedback(current === "up" ? null : "up")} className={cn(ICON_BUTTON, current === "up" && "bg-muted text-success")} > <ThumbsUp aria-hidden="true" className="h-4 w-4" /> </button> <button type="button" aria-label="Bad response" aria-pressed={current === "down"} onClick={() => setFeedback(current === "down" ? null : "down")} className={cn(ICON_BUTTON, current === "down" && "bg-muted text-destructive")} > <ThumbsDown aria-hidden="true" className="h-4 w-4" /> </button> </> ) : null} </motion.div> ) : null} {settled && followUps?.length ? ( <div className="flex flex-col"> {followUps.map((text, index) => ( <motion.button // biome-ignore lint/suspicious/noArrayIndexKey: the list is static once passed in and never reorders. key={index} type="button" onClick={() => onFollowUp?.(text, index)} initial={reduce ? false : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, ease: EASE_OUT, delay: reduce ? 0 : index * 0.05 }} className="group flex items-center gap-2 border-b border-border py-2 text-left text-sm text-foreground transition-colors duration-150 last:border-b-0 hover:text-accent" > <span className="flex-1">{text}</span> <ArrowUpRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-150 group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent motion-reduce:transition-none" /> </motion.button> ))} </div> ) : null} </div> ); }components/motion/streaming-text.tsx "use client"; import { motion, useReducedMotion } from "motion/react"; import { useEffect, useMemo, useRef } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface StreamingTextProps { /** The text so far. Append to it as more arrives; characters already shown never replay. */ text: string; /** Shows a blinking cursor at the end, for while more is still on its way. Default false. */ streaming?: boolean; className?: string; } /** Splits into individual characters, so each one can fade in on its own as it arrives. */ function tokenize(text: string): string[] { return Array.from(text); } /** * Reveals text as it streams in. Feed it a growing string and each new * character fades in fast enough to read as one smooth ribbon, not single * letters popping in. Characters already shown never replay. */ export function StreamingText({ text, streaming = false, className }: StreamingTextProps) { const reduce = useReducedMotion(); const tokens = useMemo(() => tokenize(text), [text]); // Tokens shown as of the last render. A shorter text (a new message) counts as fully settled. const shown = useRef(0); const settled = Math.min(tokens.length, shown.current); useEffect(() => { shown.current = tokens.length; }, [tokens.length]); return ( <p className={cn("text-sm leading-6 text-foreground", className)}> {tokens.map((token, index) => { const isGlyph = /\S/.test(token); if (!isGlyph || index < settled || reduce) { // biome-ignore lint/suspicious/noArrayIndexKey: streaming only appends, so a token's index never changes once shown. return <span key={index}>{token}</span>; } return ( <motion.span // biome-ignore lint/suspicious/noArrayIndexKey: streaming only appends, so a token's index never changes once shown. key={index} initial={{ opacity: 0, y: 3 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.12, ease: EASE_OUT }} > {token} </motion.span> ); })} {streaming ? ( <span aria-hidden="true" className="ml-0.5 inline-block h-3.5 w-0.5 translate-y-[2px] animate-pulse bg-foreground align-middle motion-reduce:animate-none" /> ) : null} </p> ); }components/motion/copy-button.tsx "use client"; import { Check, Copy } from "lucide-react"; import { type ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; type CopyState = "idle" | "copied" | "failed"; export interface CopyButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "value" | "onCopy"> { /** Text written to the clipboard. */ value: string; /** Visible text. Leave it out for an icon only button. */ label?: string; /** Visible text after copying. Default "Copied". */ copiedLabel?: string; /** How long the copied state stays, in ms. Default 1500. */ timeout?: number; /** Called after the text reaches the clipboard. */ onCopied?: (value: string) => void; } // Both icons, and both labels, share a grid cell and trade places with a fade and a small // scale. Sharing the cell keeps the button the same width in either state. const SWAP = "col-start-1 row-start-1 transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none"; const SHOWN = "scale-100 opacity-100"; const ICON_HIDDEN = "scale-50 opacity-0"; const TEXT_HIDDEN = "scale-[0.97] opacity-0"; export const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(function CopyButton( { value, label, copiedLabel = "Copied", timeout = 1500, onCopied, className, onClick, ...props }, ref, ) { const [state, setState] = useState<CopyState>("idle"); const timer = useRef<ReturnType<typeof setTimeout> | null>(null); useEffect( () => () => { if (timer.current) clearTimeout(timer.current); }, [], ); const copy = async () => { try { await navigator.clipboard.writeText(value); setState("copied"); onCopied?.(value); } catch { // Clipboard access can be blocked, for example in an insecure context. setState("failed"); } if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(() => setState("idle"), timeout); }; const copied = state === "copied"; const status = copied ? "Copied to clipboard" : state === "failed" ? "Could not copy" : ""; return ( <button ref={ref} type="button" aria-label={label ?? "Copy"} data-state={state} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented) void copy(); }} className={cn( "relative inline-flex h-9 shrink-0 touch-manipulation select-none items-center justify-center gap-2 rounded-full bg-card text-sm font-medium text-foreground outline-none", "shadow-[0_0_0_1px_var(--border)] transition-[background-color,scale] duration-150 ease-out hover:bg-muted active:scale-[0.97] motion-reduce:active:scale-100", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", label ? "px-3.5" : "w-9 after:absolute after:-inset-1", className, )} {...props} > <span aria-hidden="true" className="grid place-items-center"> <Copy className={cn(SWAP, "h-4 w-4", copied ? ICON_HIDDEN : SHOWN)} /> <Check className={cn(SWAP, "h-4 w-4", copied ? SHOWN : ICON_HIDDEN)} /> </span> {label ? ( <span aria-hidden="true" className="grid whitespace-nowrap"> <span className={cn(SWAP, copied ? TEXT_HIDDEN : SHOWN)}>{label}</span> <span className={cn(SWAP, copied ? SHOWN : TEXT_HIDDEN)}>{copiedLabel}</span> </span> ) : null} <span aria-live="polite" className="sr-only"> {status} </span> </button> ); });lib/ease.ts // Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI // 150 to 250ms, and panels up to 300ms. // Easing curves mirror the CSS custom properties in globals.css. /** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */ export const EASE_OUT = [0.23, 1, 0.32, 1] as const; /** ease-in-out-cubic. Elements already on screen moving to a new spot. */ export const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const; /** Sheet and drawer glide. */ export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const; /** CSS string form of EASE_OUT for inline style transitions. */ export const EASE_OUT_CSS = "cubic-bezier(0.23, 1, 0.32, 1)"; // Springs are described by duration and bounce, which is easier to reason about // than stiffness and damping. Bounce stays at zero for product UI. /** Press feedback on buttons and other tappable surfaces. */ export const SPRING_PRESS = { type: "spring", duration: 0.15, bounce: 0, } as const; /** Content swaps, label and icon slots trading places inside a control. */ export const SPRING_SWAP = { type: "spring", duration: 0.2, bounce: 0, } as const; /** Overlay panel entrances, modals and sheets summoned by pointer. */ export const SPRING_PANEL = { type: "spring", duration: 0.25, bounce: 0, } as const; /** Shared-layout glides, pills and indicators moving between positions. */ export const SPRING_LAYOUT = { type: "spring", duration: 0.22, bounce: 0, } as const; /** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */ export const SPRING_MOUSE = { stiffness: 320, damping: 26, mass: 0.3, } as const; /** Dragged handles and fills (sliders). Critically damped `useSpring` config, * so the value follows the pointer closely and never rebounds off an end. */ export const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5, } as const;
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| value? | string | - | Controlled value. |
| defaultValue? | string | Starting value when uncontrolled. Default "". | |
| onChange? | ((value: string) => void) | - | - |
| onSubmit | (value: string) => void | - | Called with the trimmed text on Enter or the send button. |
| loading? | boolean | false | Swaps the send button for a stop button. Default false. |
| onStop? | (() => void) | - | Called from the stop button while loading. |
| className? | string | - | - |
Updated