UI Elements

Emoji Feedback

An emoji-rating feedback widget that expands into a comment box, shows a sending state, and confirms submission with a staggered thank-you screen.

emoji-feedback.tsx
"use client";

import { cn } from "@/lib/utils";
import { Angry, Check, Frown, Laugh, Loader2, Smile } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";

const feedback = [
  { happiness: 4, emoji: <Laugh size={16} className="stroke-inherit" /> },
  { happiness: 3, emoji: <Smile size={16} className="stroke-inherit" /> },
  { happiness: 2, emoji: <Frown size={16} className="stroke-inherit" /> },
  { happiness: 1, emoji: <Angry size={16} className="stroke-inherit" /> },
];

export const Feedback = () => {
  const textRef = useRef<HTMLTextAreaElement>(null);
  const [happiness, setHappiness] = useState<null | number>(null);

  const [isSubmitted, setSubmissionState] = useState(false);
  const { submitFeedback, isLoading, isSent } = useSubmitFeedback();

  useEffect(() => {
    if (!happiness) {
      //cleaning up textarea
      if (textRef.current) textRef.current!.value = "";
    }
  }, [happiness]);

  useEffect(() => {
    let timeout = null;
    let submissionStateTimeout = null;

    if (isSent) {
      setSubmissionState(true);

      //cleaning up textarea and customer happiness state
      timeout = setTimeout(() => {
        setHappiness(null);
        if (textRef.current) textRef.current!.value = "";
      }, 2000);

      //cleaning up successful submission text 100ms later
      submissionStateTimeout = setTimeout(() => {
        setSubmissionState(false);
      }, 2200);
    }

    return () => {
      if (timeout) {
        clearTimeout(timeout);
      }
      if (submissionStateTimeout) {
        clearTimeout(submissionStateTimeout);
      }
    };
  }, [isSent]);

  return (
    <motion.div
      layout
      initial={{ borderRadius: "2rem" }}
      animate={
        happiness ? { borderRadius: "0.5rem" } : { borderRadius: "2rem" }
      }
      className={twMerge(
        "w-fit overflow-hidden border py-2 shadow-sm dark:border-neutral-800 dark:bg-neutral-950",
      )}
    >
      <span className="flex items-center justify-center gap-3 pl-4 pr-2">
        <div className="text-sm text-black dark:text-neutral-400">
          Like our service?
        </div>
        <div className="flex items-center text-neutral-400">
          {feedback.map((e) => (
            <button
              onClick={() =>
                setHappiness((prev) =>
                  e.happiness === prev ? null : e.happiness,
                )
              }
              className={twMerge(
                happiness === e.happiness
                  ? "bg-blue-100 stroke-blue-500 dark:bg-sky-900 dark:stroke-sky-500"
                  : "stroke-neutral-500 dark:stroke-neutral-400",
                "flex h-8 w-8 items-center justify-center rounded-full transition-all hover:bg-blue-100 hover:stroke-blue-500 hover:dark:bg-sky-900 hover:dark:stroke-sky-500",
              )}
              key={e.happiness}
            >
              {e.emoji}
            </button>
          ))}
        </div>
      </span>
      <motion.div
        aria-hidden={happiness ? false : true}
        initial={{ height: 0, translateY: 15 }}
        className="px-2"
        transition={{ ease: "easeInOut", duration: 0.3 }}
        animate={happiness ? { height: "195px", width: "330px" } : {}}
      >
        <AnimatePresence>
          {!isSubmitted ? (
            <motion.span exit={{ opacity: 0 }} initial={{ opacity: 1 }}>
              <textarea
                ref={textRef}
                placeholder="Your app is awesoooome"
                className="min-h-32 w-full resize-none rounded-md border bg-transparent p-2 text-sm placeholder-neutral-400 focus:border-neutral-400 focus:outline-0 dark:border-neutral-800 focus:dark:border-white"
              />
              <div className="flex h-fit w-full justify-end">
                <button
                  onClick={() =>
                    submitFeedback(happiness!, textRef.current!.value || "")
                  }
                  className={cn(
                    "mt-1 flex h-9 items-center justify-center rounded-md border bg-neutral-950 px-2 text-sm text-white dark:bg-background dark:text-neutral-950",
                    {
                      "bg-neutral-500 dark:bg-background dark:text-neutral-500":
                        isLoading,
                    },
                  )}
                >
                  {isLoading ? (
                    <>
                      <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                      Loading
                    </>
                  ) : (
                    "Submit"
                  )}
                </button>
              </div>
            </motion.span>
          ) : (
            <motion.div
              variants={container}
              initial="hidden"
              animate="show"
              className="flex h-full w-full flex-col items-center justify-start gap-2 pt-9 text-sm font-normal"
            >
              <motion.div
                variants={item}
                className="flex h-8 min-h-8 w-8 min-w-8 items-center justify-center rounded-full bg-blue-500 dark:bg-sky-500"
              >
                <Check strokeWidth={2.5} size={16} className="stroke-white" />
              </motion.div>
              <motion.div variants={item}>
                Your feedback has been received!
              </motion.div>
              <motion.div variants={item}>Thank you for your help.</motion.div>
            </motion.div>
          )}
        </AnimatePresence>
      </motion.div>
    </motion.div>
  );
};

const container = {
  hidden: { opacity: 0, y: 20 },
  show: {
    opacity: 1,
    y: 0,
    transition: {
      duration: 0.2,
      staggerChildren: 0.04,
    },
  },
};

const item = {
  hidden: { y: 10 },
  show: { y: 0 },
};

const useSubmitFeedback = () => {
  const [feedback, setFeedback] = useState<{
    happiness: number;
    feedback: string;
  } | null>(null);
  const [isLoading, setLoadingState] = useState(false);
  //error never happens in case of this mockup btw
  const [error, setError] = useState<{ error: any } | null>(null);
  const [isSent, setRequestState] = useState(false);

  //fake api call
  const submitFeedback = (feedback: { happiness: number; feedback: string }) =>
    new Promise((res) => setTimeout(() => res(feedback), 1000));

  useEffect(() => {
    if (feedback) {
      setLoadingState(true);
      setRequestState(false);

      submitFeedback(feedback)
        .then(() => {
          setRequestState(true);
          setError(null);
        })
        .catch(() => {
          setRequestState(false);
          setError({ error: "some error" });
        })
        .finally(() => setLoadingState(false));
    }
  }, [feedback]);

  return {
    submitFeedback: (happiness: number, feedback: string) =>
      setFeedback({ happiness, feedback }),
    isLoading,
    error,
    isSent,
  };
};

export default Feedback;