Feedback popover

Six animation lessons applied to a single component

## The starting point ```tsx <div className="relative"> <button onClick={() => setOpen(!open)}>Feedback</button> {open && ( <div className="absolute top-full mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg"> <textarea placeholder="What's on your mind?" /> <button>Send</button> </div> )} </div> ``` ## Step 1: Spatial origin ```tsx <motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }} style={{ transformOrigin: "top left" }} className="absolute top-full left-0 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg" > ``` `transformOrigin: "top left"` because the trigger is above-left. Scale from 0.9 (not 0). Exit at 0.95 (asymmetric). ## Step 2: Spring physics Replace the easing curve with a snappy spring. Springs retarget smoothly on rapid toggles instead of restarting. ```tsx transition={{ type: "spring", stiffness: 500, damping: 40 }} ``` No bounce. The spring's value is interruptibility, not overshoot. ## Step 3: Stagger the content ```tsx <motion.div initial="hidden" animate="visible" variants={{ visible: { transition: { staggerChildren: 0.05, delayChildren: 0.1 } }, hidden: {}, }} > <motion.h3 variants={itemVariants}>Send feedback</motion.h3> <motion.textarea variants={itemVariants} /> <motion.div variants={itemVariants} className="flex justify-end gap-2"> <button>Cancel</button> <button>Send</button> </motion.div> </motion.div> ``` `delayChildren: 0.1` waits for the popover to mostly finish its entrance. ```tsx const itemVariants = { hidden: { opacity: 0, y: 8 }, visible: { opacity: 1, y: 0, transition: { duration: 0.25, ease: [0.22, 1, 0.36, 1] }, }, }; ``` ## Step 4: Focus management ```tsx const popoverRef = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLButtonElement>(null); useEffect(() => { if (open) { const timer = setTimeout(() => { popoverRef.current?.querySelector("textarea")?.focus(); }, 120); return () => clearTimeout(timer); } triggerRef.current?.focus(); }, [open]); ``` 120ms puts focus transfer at roughly 60% of the spring settle time. ## Step 5: Reduced motion ```tsx const shouldReduce = useReducedMotion(); <motion.div initial={shouldReduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }} animate={shouldReduce ? { opacity: 1 } : { opacity: 1, scale: 1 }} exit={shouldReduce ? { opacity: 0 } : { opacity: 0, scale: 0.95 }} transition={ shouldReduce ? { duration: 0.1 } : { type: "spring", stiffness: 500, damping: 40 } } />; ``` ## Step 6: Swipe to dismiss on mobile ```tsx <motion.div drag="y" dragConstraints={{ top: 0, bottom: 0 }} dragElastic={0.3} onDragEnd={(_, info) => { if (info.offset.y > 80 || info.velocity.y > 500) { setOpen(false); } }} > ``` Both distance (80px) and velocity (500px/s) thresholds matter. Distance alone misses quick flicks. Velocity alone misses slow deliberate drags. `dragElastic: 0.3` provides rubber-band resistance.