A chat composer with an auto-growing textarea, Enter-to-send, a send button that wakes with content, and a blur-swap stop state while streaming.
npx shadcn@latest add @paragon/prompt-input"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowUp, Square } from "lucide-react";
import { cn } from "@/lib/utils";
export interface PromptInputProps
extends Omit<
React.ComponentProps<"form">,
"onSubmit" | "defaultValue" | "onChange"
> {
/** Controlled prompt value. */
value?: string;
/** Initial value when uncontrolled. */
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Called with the trimmed prompt when the user sends. */
onSubmit?: (value: string) => void;
/** Swaps the send button for a stop button while the model responds. */
streaming?: boolean;
/** Called when the stop button is pressed during streaming. */
onStop?: () => void;
placeholder?: string;
/** Rows the textarea grows to before it starts scrolling. */
maxRows?: number;
disabled?: boolean;
/** Disables press/scale motion. */
static?: boolean;
}
/**
* Chat composer: an auto-growing textarea over a toolbar row of chips and a
* send button. Enter sends, Shift+Enter inserts a newline (IME-safe). The
* send button wakes up with a subtle scale + color shift the moment there is
* content, and blur-swaps to a stop button while the model streams.
*
* Growth is measured (scrollHeight capped at maxRows), not animated — height
* changes track typing instantly, so nothing lags the caret.
*/
export function PromptInput({
value,
defaultValue = "",
onValueChange,
onSubmit,
streaming = false,
onStop,
placeholder = "Send a message…",
maxRows = 8,
disabled = false,
static: isStatic = false,
className,
children,
...props
}: PromptInputProps) {
const reducedMotion = useReducedMotion();
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const [internal, setInternal] = React.useState(defaultValue);
const isControlled = value !== undefined;
const text = isControlled ? value : internal;
const setText = React.useCallback(
(next: string) => {
if (!isControlled) setInternal(next);
onValueChange?.(next);
},
[isControlled, onValueChange],
);
// Auto-grow: measure content height, cap at maxRows, then let it scroll.
React.useLayoutEffect(() => {
const el = textareaRef.current;
if (!el) return;
const styles = window.getComputedStyle(el);
const line = parseFloat(styles.lineHeight) || 20;
const padding =
(parseFloat(styles.paddingTop) || 0) +
(parseFloat(styles.paddingBottom) || 0);
const max = line * maxRows + padding;
el.style.height = "0px";
el.style.height = `${Math.min(el.scrollHeight, max)}px`;
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
}, [text, maxRows]);
const canSend = text.trim().length > 0 && !disabled;
const showStop = streaming;
const submit = React.useCallback(() => {
const trimmed = text.trim();
if (!trimmed || streaming || disabled) return;
onSubmit?.(trimmed);
setText("");
}, [text, streaming, disabled, onSubmit, setText]);
const blur = reducedMotion ? "blur(0px)" : "blur(4px)";
return (
<form
data-slot="prompt-input"
className={cn(
"w-full rounded-xl bg-card text-card-foreground shadow-border transition-[box-shadow] duration-150 ease-out focus-within:shadow-border-hover",
className,
)}
onSubmit={(e) => {
e.preventDefault();
submit();
}}
{...props}
>
<textarea
ref={textareaRef}
rows={1}
value={text}
disabled={disabled}
placeholder={placeholder}
aria-label="Message"
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
}
}}
className="block w-full resize-none bg-transparent px-3.5 pt-3 pb-1 text-sm leading-6 outline-none placeholder:text-muted-foreground disabled:opacity-50"
/>
<div className="flex items-center gap-1 px-2 pb-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
{children}
</div>
<button
type={showStop ? "button" : "submit"}
onClick={showStop ? onStop : undefined}
disabled={!showStop && !canSend}
aria-label={showStop ? "Stop response" : "Send message"}
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-lg transition-[background-color,color,scale] duration-150 ease-out after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-x-1/2 after:-translate-y-1/2",
showStop || canSend
? "scale-100 bg-primary text-primary-foreground"
: "scale-90 bg-muted text-muted-foreground/50",
!isStatic && "active:not-disabled:scale-[0.95]",
)}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={showStop ? "stop" : "send"}
className="flex"
initial={{ opacity: 0, scale: 0.25, filter: blur }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: blur }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{showStop ? (
<Square className="size-3 fill-current" />
) : (
<ArrowUp className="size-4" />
)}
</motion.span>
</AnimatePresence>
</button>
</div>
</form>
);
}
export interface PromptInputChipProps extends React.ComponentProps<"button"> {
/** Disables press/scale motion. */
static?: boolean;
}
/** Small toolbar chip for attachments, model pickers, and composer options. */
export function PromptInputChip({
className,
type = "button",
static: isStatic = false,
...props
}: PromptInputChipProps) {
return (
<button
type={type}
data-slot="prompt-input-chip"
className={cn(
"relative inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-[background-color,color,scale] duration-150 ease-out after:absolute after:inset-x-0 after:-inset-y-1.5 hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0",
!isStatic && "active:not-disabled:scale-[0.97]",
className,
)}
{...props}
/>
);
}