feat: redesign drop zone with glassmorphism card and ambient animations

Floating gradient blobs drift slowly behind a frosted glass card.
Icon continuously floats up/down, pills stagger in on mount, card
and icon glow pink on drag. Scales cleanly across all device sizes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
noah
2026-04-16 15:50:15 +02:00
parent 04454b4a42
commit ed147326a3
+146 -36
View File
@@ -15,7 +15,16 @@ interface DropZoneProps {
onBrowse: () => void; onBrowse: () => void;
} }
const WORDS = ["Drop", "files", "here"]; const FORMAT_PILLS = [
{ label: "JPG", color: "bg-pink/10 text-pink" },
{ label: "PDF", color: "bg-orange/10 text-orange" },
{ label: "MP4", color: "bg-purple/10 text-purple" },
{ label: "MP3", color: "bg-blue/10 text-blue" },
{ label: "SVG", color: "bg-teal/10 text-teal" },
{ label: "CSV", color: "bg-mint/10 text-mint" },
{ label: "DOCX", color: "bg-orange/10 text-orange" },
{ label: "+60", color: "bg-[#f0ede8] text-text-light" },
];
export function DropZone({ export function DropZone({
isDragging, isDragging,
@@ -27,59 +36,160 @@ export function DropZone({
}: DropZoneProps) { }: DropZoneProps) {
return ( return (
<div <div
className="flex min-h-full w-full flex-col items-center justify-center px-6 py-10 select-none" className="relative flex min-h-full w-full items-center justify-center overflow-hidden px-5 py-10"
style={{ minHeight: "100%" }} style={{ minHeight: "100%" }}
onDragEnter={onDragEnter} onDragEnter={onDragEnter}
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
onDragOver={onDragOver} onDragOver={onDragOver}
onDrop={onDrop} onDrop={onDrop}
> >
{/* Poster headline */} {/* Animated ambient blobs */}
<div className="flex flex-col items-center leading-none mb-10">
{WORDS.map((word, i) => (
<motion.span
key={word}
className="font-serif font-extrabold tracking-tight block"
style={{ fontSize: "clamp(3.5rem, 16vw, 14rem)" }}
initial={{ opacity: 0, y: 24 }}
animate={{
opacity: 1,
y: 0,
color: isDragging ? "#f472b6" : "#2d1f14",
}}
transition={{
opacity: { duration: 0.5, delay: i * 0.07, ease: [0.16, 1, 0.3, 1] },
y: { duration: 0.5, delay: i * 0.07, ease: [0.16, 1, 0.3, 1] },
color: { duration: 0.3 },
}}
>
{word}
</motion.span>
))}
</div>
{/* Browse button — small and understated */}
<motion.div <motion.div
initial={{ opacity: 0, y: 8 }} className="pointer-events-none absolute -top-1/3 -left-1/4 w-3/4 h-3/4 rounded-full bg-pink/[0.13] blur-[90px]"
animate={{ opacity: 1, y: 0 }} animate={{ x: [0, 25, 0], y: [0, -18, 0] }}
transition={{ duration: 0.4, delay: 0.28, ease: [0.16, 1, 0.3, 1] }} transition={{ duration: 9, repeat: Infinity, ease: "easeInOut" }}
className="flex flex-col items-center gap-4" />
<motion.div
className="pointer-events-none absolute -bottom-1/3 -right-1/4 w-3/4 h-3/4 rounded-full bg-purple/[0.10] blur-[90px]"
animate={{ x: [0, -25, 0], y: [0, 18, 0] }}
transition={{ duration: 11, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
className="pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1/2 h-1/2 rounded-full bg-orange/[0.06] blur-[70px]"
animate={{ scale: [1, 1.25, 1] }}
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
/>
{/* Glass card */}
<motion.div
className="relative z-10 w-full max-w-sm flex flex-col items-center gap-5 sm:gap-6 px-7 sm:px-10 py-8 sm:py-10 text-center rounded-3xl backdrop-blur-2xl"
style={{
background: isDragging
? "rgba(255,255,255,0.82)"
: "rgba(255,255,255,0.72)",
border: isDragging
? "1px solid rgba(244,114,182,0.35)"
: "1px solid rgba(255,255,255,0.9)",
boxShadow: isDragging
? "0 0 0 5px rgba(244,114,182,0.08), 0 16px 56px rgba(244,114,182,0.14)"
: "0 8px 48px rgba(45,31,20,0.10), 0 1px 0 rgba(255,255,255,0.8) inset",
}}
initial={{ opacity: 0, y: 28, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.55, ease: [0.16, 1, 0.3, 1] }}
> >
{/* Floating icon */}
<motion.div
className="flex items-center justify-center w-[68px] h-[68px] sm:w-20 sm:h-20 rounded-2xl flex-shrink-0"
style={{
background: isDragging
? "linear-gradient(135deg, #f472b6 0%, #a78bfa 100%)"
: "linear-gradient(135deg, #f9a8d4 0%, #c4b5fd 100%)",
boxShadow: isDragging
? "0 0 0 8px rgba(244,114,182,0.14), 0 14px 40px rgba(244,114,182,0.45)"
: "0 8px 32px rgba(244,114,182,0.28)",
}}
animate={
isDragging
? { y: -10, rotate: -5, scale: 1.1 }
: { y: [0, -8, 0] }
}
transition={
isDragging
? { type: "spring", stiffness: 280, damping: 18 }
: { duration: 3, repeat: Infinity, ease: "easeInOut" }
}
>
<svg width="30" height="30" viewBox="0 0 48 48" fill="none" className="sm:w-9 sm:h-9">
<path
d="M24 32V12M24 12L16 20M24 12L32 20"
stroke="white"
strokeWidth="2.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 28v8a4 4 0 004 4h24a4 4 0 004-4v-8"
stroke="white"
strokeWidth="2.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.div>
{/* Heading + subtitle */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
>
<h2 className="font-serif text-[1.65rem] sm:text-[2rem] font-extrabold text-text-dark tracking-tight mb-1.5 leading-tight">
{isDragging ? "Release to add" : "Drop files here"}
</h2>
<p className="text-text-mid text-sm leading-relaxed">
{isDragging
? "Your files are ready for transformation"
: "Images, documents, audio, video, data"}
</p>
</motion.div>
{/* Format pills */}
{!isDragging && (
<div className="flex flex-wrap justify-center gap-1.5">
{FORMAT_PILLS.map(({ label, color }, i) => (
<motion.span
key={label}
className={`font-mono text-[10px] font-semibold tracking-wide px-2.5 py-[5px] rounded-full ${color}`}
initial={{ opacity: 0, scale: 0.75 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.35,
delay: 0.18 + i * 0.045,
ease: [0.16, 1, 0.3, 1],
}}
>
{label}
</motion.span>
))}
</div>
)}
{/* Browse button */}
<motion.button <motion.button
className="inline-flex items-center gap-2 px-5 py-2 text-sm font-semibold text-white bg-pink rounded-xl cursor-pointer border-none shadow-[0_4px_16px_rgba(244,114,182,0.3)] hover:shadow-[0_6px_24px_rgba(244,114,182,0.4)] transition-shadow" className="inline-flex items-center gap-2 px-6 py-2.5 text-sm font-bold text-white bg-pink rounded-xl cursor-pointer border-none shadow-[0_4px_20px_rgba(244,114,182,0.32)]"
onClick={onBrowse} onClick={onBrowse}
whileHover={{ scale: 1.04 }} initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.32 }}
whileHover={{
scale: 1.05,
boxShadow: "0 8px 28px rgba(244,114,182,0.46)",
}}
whileTap={{ scale: 0.96 }} whileTap={{ scale: 0.96 }}
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> <path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg> </svg>
Browse files Browse files
</motion.button> </motion.button>
<p className="font-mono text-[10px] text-text-light/50 tracking-wide"> {/* Trust signal */}
<motion.p
className="font-mono text-[10px] text-text-light/50 tracking-wide -mt-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.42 }}
>
70+ formats &mdash; 100% client-side 70+ formats &mdash; 100% client-side
</p> </motion.p>
</motion.div> </motion.div>
</div> </div>
); );