A dark AI prompt input with a rotating gradient border and a soft halo behind it. It holds removable file chips, three mode toggles and a send button that widens into a sending pill, then lands on a green sent state. An idle demo types example prompts until someone focuses the field. On phones the mode labels collapse to icons so the row never wraps.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
import { ArrowUp, Check, FileText, Globe, Image as ImageIcon, Layers, PenTool, Paperclip, X } from "lucide-react";
// Prompts the idle demo types out. The demo stops for good as soon as a visitor focuses the field.
const demoPrompts = [
"Design a booking page for a ceramics studio with class times and a gift card option.",
"Turn the attached brand guide into a pricing page with three plans and a yearly toggle.",
"Write a launch email for our spring menu and match the tone of the brief.",
];
const modes = [
{ id: "search", label: "Web search", icon: Globe },
{ id: "reason", label: "Deep reasoning", icon: Layers },
{ id: "design", label: "Design mode", icon: PenTool },
];
const filePool = [
{ name: "brand-guide.pdf", size: "2.4 MB", kind: "doc" },
{ name: "studio-photo.png", size: "860 KB", kind: "image" },
{ name: "class-times.csv", size: "12 KB", kind: "doc" },
{ name: "moodboard.png", size: "1.1 MB", kind: "image" },
];
type Status = "idle" | "sending" | "sent";
const spring = { type: "spring", stiffness: 420, damping: 30 } as const;
export default function GlowingAiPromptBoxWithMorphingSendButton() {
const reduce = useReducedMotion();
const rootRef = useRef<HTMLElement>(null);
const inView = useInView(rootRef, { amount: 0.3 });
const [value, setValue] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [demo, setDemo] = useState(true);
const [promptIndex, setPromptIndex] = useState(0);
const [active, setActive] = useState<string[]>(["design"]);
const [files, setFiles] = useState(filePool.slice(0, 2));
// Idle demo: type the prompt one character at a time, then press send.
useEffect(() => {
if (!demo || !inView || status !== "idle") return;
const target = demoPrompts[promptIndex];
if (reduce) {
setValue(target);
return;
}
const done = value.length >= target.length;
const timer = setTimeout(() => (done ? setStatus("sending") : setValue(target.slice(0, value.length + 1))), done ? 650 : 20);
return () => clearTimeout(timer);
}, [demo, inView, status, value, promptIndex, reduce]);
// Sending, then sent, then back to an empty field.
useEffect(() => {
if (status === "idle") return;
const timer = setTimeout(
() => {
if (status === "sending") return setStatus("sent");
setValue("");
setStatus("idle");
setPromptIndex((i) => (i + 1) % demoPrompts.length);
},
status === "sending" ? 1900 : 1400,
);
return () => clearTimeout(timer);
}, [status]);
const busy = status !== "idle";
const canSend = value.trim().length > 0 && !busy;
const send = () => canSend && setStatus("sending");
const toggleMode = (id: string) => setActive((list) => (list.includes(id) ? list.filter((m) => m !== id) : [...list, id]));
const attach = () => {
const next = filePool.find((f) => !files.some((x) => x.name === f.name));
if (next) setFiles((list) => [...list, next]);
};
return (
<section ref={rootRef} className="relative overflow-hidden bg-zinc-950 px-4 py-16 text-zinc-100 sm:px-6 sm:py-24">
<style>{`
@keyframes glowing-ai-prompt-box-spin { to { transform: rotate(360deg); } }
@keyframes glowing-ai-prompt-box-caret { 50% { opacity: 0; } }
.glowing-ai-prompt-box-ring {
background: conic-gradient(from 0deg, #f59e0b, #fb7185 22%, #a855f7 45%, rgba(24,24,27,0) 60%, rgba(24,24,27,0) 78%, #f59e0b);
animation: glowing-ai-prompt-box-spin 5s linear infinite;
}
.glowing-ai-prompt-box-ring-fast { animation-duration: 1.4s; }
.glowing-ai-prompt-box-caret { animation: glowing-ai-prompt-box-caret 1s steps(1) infinite; }
@media (prefers-reduced-motion: reduce) {
.glowing-ai-prompt-box-ring, .glowing-ai-prompt-box-caret { animation: none; }
}
`}</style>
<div aria-hidden="true" className="pointer-events-none absolute left-1/2 top-1/2 h-[520px] w-[820px] max-w-none -translate-x-1/2 -translate-y-1/3 rounded-full bg-gradient-to-r from-amber-500/20 via-rose-500/20 to-purple-600/20 blur-3xl" />
<div className="relative mx-auto max-w-2xl">
<motion.div initial={reduce ? false : { opacity: 0, y: 14 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }} className="text-center">
<p className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-zinc-300">
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
Kiln Studio assistant
</p>
<h2 className="mt-5 text-3xl font-semibold tracking-tight text-white sm:text-5xl">
Describe it. <span className="bg-gradient-to-r from-amber-300 via-rose-300 to-purple-300 bg-clip-text text-transparent">Watch it take shape.</span>
</h2>
<p className="mx-auto mt-4 max-w-md text-sm text-zinc-400 sm:text-base">Attach a brief, pick how the assistant should work, and send. A first draft is ready before your coffee cools.</p>
</motion.div>
<motion.div initial={reduce ? false : { opacity: 0, y: 24, scale: 0.97 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ ...spring, delay: 0.08 }} className="relative mt-10">
{/* Soft halo: the same spinning gradient, blurred, sitting behind the box. */}
<motion.div aria-hidden="true" animate={{ opacity: status === "sending" ? 0.9 : 0.4, scale: status === "sending" ? 1.03 : 1 }} transition={{ duration: 0.5 }} className="absolute -inset-2 overflow-hidden rounded-[32px] blur-2xl">
<div className={`glowing-ai-prompt-box-ring absolute left-1/2 top-1/2 -ml-[60%] -mt-[60%] aspect-square w-[120%] ${status === "sending" ? "glowing-ai-prompt-box-ring-fast" : ""}`} style={{ animationPlayState: inView ? "running" : "paused" }} />
</motion.div>
{/* The border itself: a 1.5px frame that shows the spinning gradient through its padding. */}
<div className="relative overflow-hidden rounded-[26px] bg-white/10 p-[1.5px]">
<div aria-hidden="true" className={`glowing-ai-prompt-box-ring absolute left-1/2 top-1/2 -ml-[60%] -mt-[60%] aspect-square w-[120%] ${status === "sending" ? "glowing-ai-prompt-box-ring-fast" : ""}`} style={{ animationPlayState: inView ? "running" : "paused" }} />
<form
onSubmit={(e) => {
e.preventDefault();
send();
}}
className="relative rounded-[25px] bg-zinc-900/95 p-3 sm:p-4"
>
<ul className="flex flex-wrap gap-2" aria-label="Attached files">
<AnimatePresence initial={false} mode="popLayout">
{files.map((file) => (
<motion.li key={file.name} layout initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} transition={spring} className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 py-1.5 pl-1.5 pr-1">
<span className={`grid h-8 w-8 flex-none place-items-center rounded-lg ${file.kind === "image" ? "bg-gradient-to-br from-rose-400 to-purple-500" : "bg-gradient-to-br from-amber-400 to-orange-500"} text-zinc-950`}>
{file.kind === "image" ? <ImageIcon aria-hidden="true" className="h-4 w-4" /> : <FileText aria-hidden="true" className="h-4 w-4" />}
</span>
<span className="min-w-0">
<span className="block max-w-[120px] truncate text-xs font-medium text-zinc-100">{file.name}</span>
<span className="block text-[11px] text-zinc-400">{file.size}</span>
</span>
<button type="button" aria-label={`Remove ${file.name}`} onClick={() => setFiles((list) => list.filter((f) => f.name !== file.name))} className="grid h-8 w-8 place-items-center rounded-lg text-zinc-400 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400">
<X aria-hidden="true" className="h-3.5 w-3.5" />
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
<div className="relative mt-3">
<label htmlFor="glowing-ai-prompt-box-field" className="sr-only">
Message the assistant
</label>
<textarea
id="glowing-ai-prompt-box-field"
rows={3}
value={value}
readOnly={busy}
onFocus={() => setDemo(false)}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}}
placeholder="Ask for a page, a section or a rewrite..."
className={`block w-full resize-none bg-transparent px-1.5 text-base leading-7 text-zinc-100 placeholder:text-zinc-500 transition-opacity duration-300 focus:outline-none ${busy ? "opacity-40" : "opacity-100"}`}
/>
{demo && status === "idle" && value.length > 0 && value.length < demoPrompts[promptIndex].length && (
<span aria-hidden="true" className="pointer-events-none absolute inset-0 whitespace-pre-wrap break-words px-1.5 text-base leading-7 text-transparent">
{value}
<span className="glowing-ai-prompt-box-caret ml-0.5 inline-block h-5 w-0.5 translate-y-1 rounded bg-amber-300" />
</span>
)}
</div>
<div className="mt-2 flex items-center gap-1.5 sm:gap-2">
<button type="button" onClick={attach} aria-label="Attach a file" className="grid h-10 w-10 flex-none place-items-center rounded-full border border-white/10 text-zinc-300 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400">
<Paperclip aria-hidden="true" className="h-4 w-4" />
</button>
<div className="flex min-w-0 flex-1 items-center gap-1.5 sm:gap-2" role="group" aria-label="Assistant modes">
{modes.map((mode) => {
const on = active.includes(mode.id);
const Icon = mode.icon;
return (
<motion.button key={mode.id} type="button" layout transition={spring} whileTap={{ scale: 0.94 }} aria-pressed={on} aria-label={mode.label} onClick={() => toggleMode(mode.id)} className={`flex h-10 items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 ${on ? "border-amber-300/40 bg-amber-300/10 text-amber-200" : "border-white/10 text-zinc-400 hover:bg-white/5 hover:text-zinc-100"}`}>
<Icon aria-hidden="true" className="h-4 w-4 flex-none" />
<span className={on ? "hidden min-[420px]:inline" : "hidden sm:inline"}>{mode.label}</span>
</motion.button>
);
})}
</div>
<motion.button
type="submit"
layout
transition={spring}
whileTap={canSend ? { scale: 0.92 } : undefined}
aria-disabled={!canSend}
aria-label={status === "sending" ? "Sending" : status === "sent" ? "Sent" : "Send message"}
className={`relative flex h-10 flex-none items-center justify-center gap-2 overflow-hidden rounded-full text-sm font-semibold focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 ${status === "idle" ? "w-10" : "px-4"} ${status === "sent" ? "bg-emerald-400 text-emerald-950" : status === "sending" ? "bg-gradient-to-r from-amber-300 via-rose-300 to-purple-300 text-zinc-950" : canSend ? "bg-white text-zinc-950" : "bg-white/10 text-zinc-500"}`}
>
<AnimatePresence mode="popLayout" initial={false}>
{status === "idle" && (
<motion.span key="idle" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -16 }} transition={spring}>
<ArrowUp aria-hidden="true" className="h-4 w-4" strokeWidth={2.5} />
</motion.span>
)}
{status === "sending" && (
<motion.span key="sending" initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -12 }} transition={spring} className="flex items-center gap-2">
<span aria-hidden="true" className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-zinc-950/25 border-t-zinc-950 motion-reduce:animate-none" />
Sending
</motion.span>
)}
{status === "sent" && (
<motion.span key="sent" initial={{ opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, y: -12 }} transition={{ type: "spring", stiffness: 500, damping: 18 }} className="flex items-center gap-1.5">
<Check aria-hidden="true" className="h-4 w-4" strokeWidth={3} />
Sent
</motion.span>
)}
</AnimatePresence>
</motion.button>
</div>
</form>
</div>
</motion.div>
<div className="mt-4 flex items-center justify-between gap-4 px-2 text-xs text-zinc-500">
<p aria-live="polite" className="text-zinc-400">
{status === "sending" ? "Reading your brief and attachments..." : status === "sent" ? "Got it. Your first draft is on the way." : "Enter to send, Shift + Enter for a new line"}
</p>
<p className="flex-none tabular-nums">{value.length} / 600</p>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/glowing-ai-prompt-box-with-morphing-send-button.json
The frame is a rounded box with 1.5 pixels of padding and hidden overflow. Behind the content sits a square layer, larger than the box, filled with a conic gradient and rotated by a keyframe. Only the padding lets it show, so it reads as a light travelling around the edge. A blurred copy behind the box makes the halo.
The button has the framer-motion `layout` prop, so when its content changes from an icon to a label its width animates with a spring instead of jumping. `AnimatePresence` slides the old content out and the new content in.
Nothing here calls a service. In the `send` function set the status to sending, make your request, then set the status to sent when the first token arrives or the request resolves. The rest of the interface follows the status value.
Yes. Set the initial `demo` state to `false`. The demo also stops by itself the first time the field receives focus, and it pauses while the component is off screen.
It animates only `transform` on two layers, which the browser handles on the compositor. The animation is paused through `animation-play-state` whenever the section leaves the viewport.
Related: Streaming AI Chat Thread with Typing Indicator, Suggested Prompt Cards That Fill the Prompt Box, Voice Input Bar with Live Waveform.
More ai prompt and chat ui or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.