Loaders, AI & Widgetsv0.1.0
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.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport 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 } }, }; }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
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | Controlled text. |
defaultValue | string | "" | 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. |
placeholder | string | "Ask anything…" | Placeholder text. |
loading | boolean | false | A response is streaming: the border orbits and the send button becomes stop. |
onStop | () => void | – | Called when stop is pressed while loading. |
allowAttachments | boolean | true | Allow attaching files (button and drag-and-drop). |
accept | string | – | File types for the picker, e.g. "image/*,.pdf". |
maxRows | number | 8 | Rows the textarea grows to before it scrolls. |
toolbar | ReactNode | – | 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.