An AI-style prompt input that fans out a stack of preview cards and swaps its action icons the moment the user starts typing.
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
ArrowUp,
Camera,
CodeXml,
FileText,
Folder,
Image,
Mic,
Mountain,
Plus,
} from "lucide-react";
import { useState } from "react";
const cards = [
{ id: 1, icon: FileText },
{ id: 2, icon: Mountain },
{ id: 3, icon: CodeXml }, // front / center card
];
const attachments = [
{ id: 1, icon: Camera, label: "Camera" },
{ id: 2, icon: Image, label: "Photos" },
{ id: 3, icon: Folder, label: "Files" },
];
// Easings — ease-out for enter/exit, ease-in-out for on-screen morphs.
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
const EASE_IN_OUT = [0.65, 0, 0.35, 1] as const;
// Resting (stacked) vs. active (fanned) transforms for each card.
const stacked = [
{ x: 0, y: -34, scale: 0.86, rotate: 0, z: 1 },
{ x: 0, y: -17, scale: 0.93, rotate: 0, z: 2 },
{ x: 0, y: 0, scale: 1, rotate: 0, z: 3 },
];
const fanned = [
{ x: -210, y: 26, scale: 0.9, rotate: -11, z: 2 },
{ x: 210, y: 26, scale: 0.9, rotate: 11, z: 2 },
{ x: 0, y: 0, scale: 1, rotate: 0, z: 3 },
];
const AiPrompt = () => {
const reduce = useReducedMotion();
const [value, setValue] = useState("");
const active = value.trim().length > 0;
const cardTransition = reduce
? { duration: 0 }
: { type: "spring" as const, bounce: 0.24, duration: 0.6 };
return (
<div className="size-full flex flex-col items-center justify-center gap-9 px-4">
{/* Card stack — fans out while typing */}
<div className="relative flex h-[240px] w-full items-center justify-center">
{cards.map((card, index) => {
const pose = active ? fanned[index] : stacked[index];
return (
<motion.div
key={card.id}
initial={false}
animate={{
x: pose.x,
y: pose.y,
scale: pose.scale,
rotate: pose.rotate,
}}
transition={{ ...cardTransition, delay: reduce ? 0 : index * 0.03 }}
style={{ zIndex: pose.z }}
className="group absolute flex h-[196px] w-[300px] items-center justify-center overflow-hidden rounded-[28px] border border-border bg-background shadow-[0_18px_40px_-24px_rgba(0,0,0,0.7)]"
>
<div className="flex size-full flex-col items-center justify-center gap-4">
<card.icon
className="size-9 text-muted-foreground/70 transition-colors duration-300 group-hover:text-foreground"
strokeWidth={1.5}
/>
<div className="flex w-full flex-col items-center gap-1.5">
<div className="h-3.5 w-1/3 rounded-full bg-muted-foreground/70 transition-colors duration-300 group-hover:bg-foreground/80" />
<div className="h-3.5 w-1/2 rounded-full bg-muted" />
</div>
</div>
</motion.div>
);
})}
</div>
{/* Composer */}
<div className="flex w-full max-w-[540px] items-center gap-2">
{/* Leading control: attachment icons ⇄ add button */}
<div className="flex items-center">
<AnimatePresence mode="popLayout" initial={false}>
{active ? (
<motion.button
key="add"
type="button"
aria-label="Add attachment"
layout
initial={reduce ? false : { opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
whileTap={{ scale: 0.92 }}
className="flex size-9 shrink-0 items-center justify-center rounded-full border border-border bg-muted/40 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
>
<Plus className="size-[18px]" />
</motion.button>
) : (
<motion.div
key="attachments"
layout
className="flex items-center gap-0.5"
>
{attachments.map((item, index) => (
<motion.button
key={item.id}
type="button"
aria-label={item.label}
initial={reduce ? false : { opacity: 0, x: 8, filter: "blur(4px)" }}
animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, x: 6, filter: "blur(4px)" }
}
transition={{
duration: 0.2,
ease: EASE_OUT,
delay: reduce ? 0 : index * 0.04,
}}
whileTap={{ scale: 0.9 }}
className="flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
>
<item.icon className="size-5" strokeWidth={1.75} />
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Input pill — flex-grows smoothly as the leading control collapses */}
<motion.div
layout
transition={{ layout: { duration: 0.32, ease: EASE_IN_OUT } }}
className="flex h-11 flex-1 items-center rounded-full border border-border bg-background px-4 transition-colors duration-200 focus-within:border-foreground/25 focus-within:ring-4 focus-within:ring-foreground/5"
>
<input
aria-label="Message"
placeholder="Message"
value={value}
onChange={(e) => setValue(e.target.value)}
className="h-full w-full border-none bg-transparent text-sm text-foreground caret-foreground outline-none placeholder:text-muted-foreground"
/>
</motion.div>
{/* Trailing action — one button: voice input ⇄ send */}
<motion.button
type="button"
aria-label={active ? "Send message" : "Voice input"}
whileTap={{ scale: 0.92 }}
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<AnimatePresence mode="wait" initial={false}>
{active ? (
<motion.span
key="send"
initial={reduce ? false : { opacity: 0, scale: 0.5, rotate: -25 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, rotate: 25 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
>
<ArrowUp className="size-5" />
</motion.span>
) : (
<motion.span
key="voice"
initial={reduce ? false : { opacity: 0, scale: 0.5, rotate: 25 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, rotate: -25 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
>
<Mic className="size-5" strokeWidth={2} />
</motion.span>
)}
</AnimatePresence>
</motion.button>
</div>
</div>
);
};
export default AiPrompt;