Skip to content

AI Prompt Input

Auto-growing input with attachments and an orbiting border.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/ai-prompt-input.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/ai-prompt-input/ai-prompt-input.tsx
    "use client";
    
    import { useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type DragEvent, type FormEvent, type KeyboardEvent, type ReactNode } from "react";
    import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
    import { ArrowUp, FileText, Paperclip, Square, X } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration, ease } from "@/lib/motion";
    
    export interface PromptAttachment {
      /** Stable identity. */
      id: string;
      /** File name shown on the chip. */
      name: string;
      /** Size in bytes. */
      size?: number;
      /** The file itself, when it came from the picker or a drop. */
      file?: File;
    }
    
    export interface AIPromptInputProps
      extends Omit<ComponentProps<"form">, "children" | "onSubmit" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Controlled text. */
      value?: string;
      /** Initial text when uncontrolled. @default "" */
      defaultValue?: string;
      /** Called as the text changes. */
      onValueChange?: (value: string) => void;
      /** Called on Enter or the send button with the text and attachments; the input then clears. */
      onSubmit?: (value: string, attachments: PromptAttachment[]) => void;
      /** Placeholder text. @default "Ask anything…" */
      placeholder?: string;
      /** A response is streaming: the border orbits and the send button becomes stop. @default false */
      loading?: boolean;
      /** Called when stop is pressed while loading. */
      onStop?: () => void;
      /** Allow attaching files (button and drag-and-drop). @default true */
      allowAttachments?: boolean;
      /** File types for the picker, e.g. "image/*,.pdf". */
      accept?: string;
      /** Rows the textarea grows to before it scrolls. @default 8 */
      maxRows?: number;
      /** Extra controls on the left of the toolbar, e.g. a model picker. */
      toolbar?: ReactNode;
    }
    
    const formatSize = (bytes?: number) =>
      bytes === undefined ? "" : bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1048576).toFixed(1)} MB`;
    
    export function AIPromptInput({
      value,
      defaultValue = "",
      onValueChange,
      onSubmit,
      placeholder = "Ask anything…",
      loading = false,
      onStop,
      allowAttachments = true,
      accept,
      maxRows = 8,
      toolbar,
      className,
      "aria-label": ariaLabel = "Message",
      ...props
    }: AIPromptInputProps) {
      const reduceMotion = useReducedMotion();
      const [internal, setInternal] = useState(defaultValue);
      const text = value ?? internal;
      const [attachments, setAttachments] = useState<PromptAttachment[]>([]);
      const [dragging, setDragging] = useState(false);
      const textareaRef = useRef<HTMLTextAreaElement>(null);
      const fileRef = useRef<HTMLInputElement>(null);
      const counter = useRef(0);
      const fieldId = useId();
      const canSend = !loading && (text.trim().length > 0 || attachments.length > 0);
      const [orbit, animateOrbit] = useAnimate<HTMLSpanElement>();
    
      // The orbit loop is started imperatively, so it also runs when the input first renders already loading.
      useEffect(() => {
        if (!loading || reduceMotion || !orbit.current) return;
        const controls = animateOrbit(orbit.current, { rotate: [0, 360] }, { duration: 2.2, repeat: Infinity, ease: "linear" });
        return () => controls.stop();
      }, [loading, reduceMotion, animateOrbit, orbit]);
    
      const setText = (next: string) => {
        if (value === undefined) setInternal(next);
        onValueChange?.(next);
      };
    
      // Grow with the content up to maxRows, then scroll.
      useLayoutEffect(() => {
        const el = textareaRef.current;
        if (!el) return;
        el.style.height = "auto";
        const line = parseFloat(getComputedStyle(el).lineHeight) || 24;
        el.style.height = `${Math.min(el.scrollHeight, line * maxRows)}px`;
      }, [text, maxRows]);
    
      const addFiles = (files: FileList | null) => {
        if (!files?.length) return;
        // Copy now: the FileList is live and empties when the input is reset right after.
        const added = Array.from(files, (file) => ({ id: `${file.name}-${counter.current++}`, name: file.name, size: file.size, file }));
        setAttachments((list) => [...list, ...added]);
      };
    
      const submit = (event?: FormEvent) => {
        event?.preventDefault();
        if (!canSend) return;
        onSubmit?.(text.trim(), attachments);
        setText("");
        setAttachments([]);
      };
    
      const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
        // Enter sends, Shift+Enter adds a line; never send mid-composition (IME).
        if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
          event.preventDefault();
          submit();
        }
      };
    
      const onDrop = (event: DragEvent<HTMLFormElement>) => {
        if (!allowAttachments) return;
        event.preventDefault();
        setDragging(false);
        addFiles(event.dataTransfer.files);
      };
    
      return (
        <form
          onSubmit={submit}
          onDragOver={(event) => {
            if (!allowAttachments || !event.dataTransfer.types.includes("Files")) return;
            event.preventDefault();
            setDragging(true);
          }}
          onDragLeave={(event) => !event.currentTarget.contains(event.relatedTarget as Node) && setDragging(false)}
          onDrop={onDrop}
          className={cn("group/prompt relative isolate overflow-hidden rounded-[18px] p-px", className)}
          {...props}
        >
          {/* Border: a hairline at rest, accent on focus, and a conic sweep that orbits while loading. */}
          <span
            aria-hidden="true"
            className={cn(
              "absolute inset-0 -z-10 rounded-[inherit] transition-colors duration-200",
              dragging ? "bg-primary" : "bg-border group-focus-within/prompt:bg-primary/45",
            )}
          />
          <motion.span
            ref={orbit}
            aria-hidden="true"
            className="absolute top-1/2 left-1/2 -z-10 aspect-square w-[160%] -translate-x-1/2 -translate-y-1/2"
            style={{ background: "conic-gradient(from 0deg, transparent 0deg, var(--primary) 70deg, transparent 140deg)" }}
            initial={false}
            animate={{ opacity: loading ? 1 : 0 }}
            transition={{ duration: 0.3 }}
          />
          <div className="rounded-[17px] bg-background">
            <AnimatePresence initial={false}>
              {attachments.length ? (
                <motion.ul
                  aria-label="Attachments"
                  className="flex flex-wrap gap-2 px-3 pt-3"
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0 }}
                >
                  <AnimatePresence initial={false}>
                    {attachments.map((attachment) => (
                      <motion.li
                        key={attachment.id}
                        layout={!reduceMotion}
                        initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: "blur(4px)" }}
                        animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                        exit={{ opacity: 0, scale: reduceMotion ? 1 : 0.9, transition: { duration: duration.quick } }}
                        transition={{ duration: duration.base, ease: ease.out }}
                        className="flex max-w-56 items-center gap-2 rounded-lg border border-border bg-surface py-1.5 pr-1 pl-2 text-xs"
                      >
                        <FileText className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
                        <span className="min-w-0 truncate text-foreground">{attachment.name}</span>
                        {attachment.size !== undefined ? <span className="shrink-0 text-muted-foreground">{formatSize(attachment.size)}</span> : null}
                        <button
                          type="button"
                          onClick={() => setAttachments((list) => list.filter((a) => a.id !== attachment.id))}
                          aria-label={`Remove ${attachment.name}`}
                          className="relative after:absolute after:-inset-2 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                        >
                          <X className="size-3" aria-hidden="true" />
                        </button>
                      </motion.li>
                    ))}
                  </AnimatePresence>
                </motion.ul>
              ) : null}
            </AnimatePresence>
            <label htmlFor={fieldId} className="sr-only">
              {ariaLabel}
            </label>
            <textarea
              ref={textareaRef}
              id={fieldId}
              rows={1}
              value={text}
              onChange={(event) => setText(event.target.value)}
              onKeyDown={onKeyDown}
              placeholder={placeholder}
              className="block w-full resize-none bg-transparent px-4 pt-3.5 pb-2 text-[15px] leading-6 text-foreground outline-none placeholder:text-muted-foreground"
            />
            <div className="flex items-center gap-2 px-2.5 pb-2.5">
              {allowAttachments ? (
                <>
                  <input ref={fileRef} type="file" multiple accept={accept} className="hidden" onChange={(event) => { addFiles(event.target.files); event.target.value = ""; }} tabIndex={-1} />
                  <button
                    type="button"
                    onClick={() => fileRef.current?.click()}
                    aria-label="Attach files"
                    className="relative after:absolute after:-inset-1 inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                  >
                    <Paperclip className="size-4" aria-hidden="true" />
                  </button>
                </>
              ) : null}
              {toolbar}
              <div className="flex-1" />
              {loading ? (
                <button
                  type="button"
                  onClick={onStop}
                  aria-label="Stop generating"
                  className="relative after:absolute after:-inset-1 inline-flex size-8 cursor-pointer items-center justify-center rounded-full bg-foreground text-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
                >
                  <Square className="size-3 fill-current" aria-hidden="true" />
                </button>
              ) : (
                <motion.button
                  type="submit"
                  aria-label="Send message"
                  disabled={!canSend}
                  animate={{ scale: canSend || reduceMotion ? 1 : 0.92 }}
                  transition={{ type: "spring", stiffness: 500, damping: 28 }}
                  className="relative after:absolute after:-inset-1 inline-flex size-8 cursor-pointer items-center justify-center rounded-full bg-foreground text-background outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-30"
                >
                  <ArrowUp className="size-4" aria-hidden="true" />
                </motion.button>
              )}
            </div>
          </div>
        </form>
      );
    }

Usage

import { AIPromptInput } from "@/components/ui/ai-prompt-input/ai-prompt-input";

export function Composer() {
  const { send, stop, isStreaming } = useChat();
  return (
    <AIPromptInput
      loading={isStreaming}
      onStop={stop}
      onSubmit={(text, attachments) => send(text, attachments.map((a) => a.file))}
    />
  );
}

API reference

PropTypeDefaultDescription
valuestring–Controlled text.
defaultValuestring""Initial text when uncontrolled.
onValueChange(value: string) => void–Called as the text changes.
onSubmit(value: string, attachments: PromptAttachment[]) => void–Called on Enter or the send button with the text and attachments; the input then clears.
placeholderstring"Ask anything…"Placeholder text.
loadingbooleanfalseA response is streaming: the border orbits and the send button becomes stop.
onStop() => void–Called when stop is pressed while loading.
allowAttachmentsbooleantrueAllow attaching files (button and drag-and-drop).
acceptstring–File types for the picker, e.g. "image/*,.pdf".
maxRowsnumber8Rows the textarea grows to before it scrolls.
toolbarReactNode–Extra controls on the left of the toolbar, e.g. a model picker.

Also accepts every native <form> attribute, including ref and className.

  • The textarea grows with its content up to maxRows, then scrolls.
  • Files come from the paperclip button or a drag-and-drop onto the field. Each attachment keeps its File so you can upload it.
  • The orbiting border is a conic gradient rotated behind a 1px ring, so only a transform animates.

Accessibility

  • A labelled textarea: Enter sends, Shift+Enter adds a line, and nothing sends while an input method (IME) is composing.
  • Attachments are a list whose chips have “Remove …” buttons; the send button becomes “Stop generating” while loading.
  • With reduced motion enabled, the border lights up without orbiting and chips appear without blur.