Layout

Feedback Card

An interactive feedback collection component with star ratings, emoji reactions, text input, and smooth submission animations.

feedback-card.tsx
"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>
    )
}