An interactive feedback collection component with star ratings, emoji reactions, text input, and smooth submission animations.
"use client"
import { useEffect, useRef, useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { CheckCircle } from "lucide-react"
import { Spinner } from "@/components/ui/spinner"
export const useClick = (callback: () => void) => {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClick = (mouse: MouseEvent) => {
if (ref.current && !ref.current.contains(mouse.target as Node)) {
callback()
}
}
document.addEventListener("click", handleClick)
return () => {
document.removeEventListener("click", handleClick)
}
}, [callback])
return ref
}
import type { Variants } from "motion/react"
// ease-out-quint for entering elements
const easeOutQuint: [number, number, number, number] = [0.23, 1, 0.32, 1];
const formVariants: Variants = {
hidden: { opacity: 0, y: 8, filter: "blur(4px)" },
visible: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 0.3, ease: easeOutQuint },
},
exit: {
opacity: 0,
y: -8,
filter: "blur(4px)",
transition: { duration: 0.2, ease: [0.25, 0.46, 0.45, 0.94] as [number, number, number, number] },
},
};
const successVariants: Variants = {
hidden: { opacity: 0, scale: 0.9, filter: "blur(6px)" },
visible: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: {
duration: 0.4,
delay: 0.15,
ease: easeOutQuint,
},
},
exit: {
opacity: 0,
scale: 0.95,
transition: { duration: 0.2 },
},
};
export default function FeedbackCard() {
const [open, setOpen] = useState(false)
const [text, setText] = useState("")
const ref = useClick(() => {
setOpen(false)
setText("") // reset when clicking outside
})
const [loading, setLoading] = useState(false)
const [submitted, setSubmitted] = useState(false)
return (
<div ref={ref} className="relative flex justify-center items-center">
<AnimatePresence>
{!open && (
<motion.button
key="button"
layoutId="card"
onClick={() => setOpen(true)}
aria-label="Open feedback form"
className="flex items-center justify-center rounded-xl bg-neutral-700 text-neutral-200 border border-neutral-800 cursor-pointer text-center text-sm py-2 px-4 shadow-md"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.25, ease: easeOutQuint }}
>
<motion.h2 layoutId="title">Feedback</motion.h2>
</motion.button>
)}
{open && (
<motion.div
key="card"
layoutId="card"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95, y: 8 }}
transition={{ duration: 0.3, ease: easeOutQuint }}
className="absolute w-91 rounded-xl bg-neutral-700 p-1 shadow-sm border border-neutral-800"
>
<div className="rounded-lg bg-neutral-800 min-h-40 flex flex-col justify-between">
<AnimatePresence mode="wait">
{!submitted ? (
<motion.div
key="form"
variants={formVariants}
initial="hidden"
animate="visible"
exit="exit"
className="flex flex-col"
>
<div className="relative w-full">
<textarea
required
rows={3}
value={text}
onChange={(e) => setText(e.target.value)}
aria-label="Feedback"
className="peer w-full resize-none rounded-lg bg-neutral-800 p-3 text-sm text-white outline-none"
/>
{text.length === 0 && (
<label className="absolute left-3 top-3 text-neutral-500 text-sm pointer-events-none">
Feedback
</label>
)}
</div>
{/* Divider + Submit */}
<div className="relative flex h-12 items-center px-2.5 mt-3">
{/* Dotted line */}
<svg
className="absolute left-0 right-0 top-0"
width="100%"
height="2"
viewBox="0 0 352 2"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 1H352" stroke="#404040" strokeDasharray="4 4" />
</svg>
{/* Left cap */}
<div className="absolute left-0 top-0 -translate-x-[1.5px] -translate-y-1/2 rounded-full">
<svg
width="6"
height="12"
viewBox="0 0 6 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_2029_22)">
<path
d="M0 2C0.656613 2 1.30679 2.10346 1.91341 2.30448C2.52005 2.5055 3.07124 2.80014 3.53554 3.17157C3.99982 3.54301 4.36812 3.98396 4.6194 4.46927C4.87067 4.95457 5 5.47471 5 6C5 6.52529 4.87067 7.04543 4.6194 7.53073C4.36812 8.01604 3.99982 8.45699 3.53554 8.82843C3.07124 9.19986 2.52005 9.4945 1.91341 9.69552C1.30679 9.89654 0.656613 10 0 10V6V2Z"
fill="#404040"
/>
<path
d="M1 12V10C2.06087 10 3.07828 9.57857 3.82843 8.82843C4.57857 8.07828 5 7.06087 5 6C5 4.93913 4.57857 3.92172 3.82843 3.17157C3.07828 2.42143 2.06087 2 1 2V0"
stroke="#404040"
strokeWidth="1"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_2029_22">
<rect width="6" height="12" fill="#404040" />
</clipPath>
</defs>
</svg>
</div>
{/* Right cap */}
<div className="absolute right-0 top-0 -translate-y-1/2 translate-x-[1.5px] rotate-180 rounded-full">
<svg
width="6"
height="12"
viewBox="0 0 6 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip1_2029_22)">
<path
d="M0 2C0.656613 2 1.30679 2.10346 1.91341 2.30448C2.52005 2.5055 3.07124 2.80014 3.53554 3.17157C3.99982 3.54301 4.36812 3.98396 4.6194 4.46927C4.87067 4.95457 5 5.47471 5 6C5 6.52529 4.87067 7.04543 4.6194 7.53073C4.36812 8.01604 3.99982 8.45699 3.53554 8.82843C3.07124 9.19986 2.52005 9.4945 1.91341 9.69552C1.30679 9.89654 0.656613 10 0 10V6V2Z"
fill="#404040"
/>
<path
d="M1 12V10C2.06087 10 3.07828 9.57857 3.82843 8.82843C4.57857 8.07828 5 7.06087 5 6C5 4.93913 4.57857 3.92172 3.82843 3.17157C3.07828 2.42143 2.06087 2 1 2V0"
stroke="#404040"
strokeWidth="1"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip1_2029_22">
<rect width="6" height="12" fill="#404040" />
</clipPath>
</defs>
</svg>
</div>
{/* Submit button */}
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
onClick={() => {
if (!text.trim()) return
setLoading(true)
setTimeout(() => {
setLoading(false)
setSubmitted(true)
setTimeout(() => {
setSubmitted(false)
setOpen(false)
setText("") // reset after successful submission
}, 1500)
}, 2000)
}}
className="ml-auto mt-4 cursor-pointer flex h-8 w-30 items-center justify-center rounded-md bg-white px-3 text-xs font-medium text-neutral-900 hover:bg-neutral-200 transition-colors"
>
{loading ? <Spinner /> : "Send feedback"}
</motion.button>
</div>
</motion.div>
) : (
<motion.div
key="success"
variants={successVariants}
initial="hidden"
animate="visible"
exit="exit"
className="flex flex-col items-center justify-center p-6 text-center space-y-2 flex-1"
>
<motion.div
initial={{ scale: 0, rotate: -90 }}
animate={{ scale: 1, rotate: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
delay: 0.2,
}}
>
<CheckCircle className="w-8 h-8 text-neutral-400" />
</motion.div>
<h2 className="font-semibold text-white text-sm">
Feedback received!
</h2>
<p className="text-xs text-gray-300">
Thanks for helping me improve Sonner.
</p>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}