AI & Chat

Prompt Input

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.

Install

npx shadcn@latest add @paragon/prompt-input

prompt-input.tsx

"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}
    />
  );
}