UI Elements

Feedback Popover

A feedback button that expands into a popover form with shared-layout animation, sending state, and a success confirmation.

feedback-popover.tsx
"use client";

import { CheckCircle2, Loader } from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";

function useClickOutside<T extends HTMLElement>(
  ref: React.RefObject<T | null>,
  handler: (event: MouseEvent | TouchEvent) => void,
) {
  useEffect(() => {
    const listener = (event: MouseEvent | TouchEvent) => {
      const el = ref.current;
      if (!el || el.contains(event.target as Node)) return;
      handler(event);
    };
    document.addEventListener("mousedown", listener);
    document.addEventListener("touchstart", listener);
    return () => {
      document.removeEventListener("mousedown", listener);
      document.removeEventListener("touchstart", listener);
    };
  }, [ref, handler]);
}

const TRANSITION = {
  type: "spring",
  bounce: 0.05,
  duration: 0.3,
};

const Feedback = () => {
  const uniqueId = useId();
  const formContainerRef = useRef<HTMLDivElement>(null);
  const [isOpen, setIsOpen] = useState(false);
  const [feedback, setFeedback] = useState<null | string>(null);
  const [isSending, setIsSending] = useState(false);
  const [isSent, setIsSent] = useState(false);

  const openMenu = () => {
    setIsOpen(true);
  };

  const closeMenu = () => {
    setIsSending(true);
    setTimeout(() => {
      setIsSending(false);
      setIsSent(true);
    }, 1000);

    setTimeout(() => {
      setIsSent(false);
      setIsOpen(false);
      setFeedback(null);
    }, 3000);
  };

  useClickOutside(formContainerRef, () => {
    setIsOpen(false);
  });

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        closeMenu();
      }
    };

    document.addEventListener("keydown", handleKeyDown);

    return () => {
      document.removeEventListener("keydown", handleKeyDown);
    };
  }, []);

  return (
    <div className="flex items-center justify-center size-full">
      <MotionConfig transition={TRANSITION}>
        <div className="relative flex items-center justify-center">
          <motion.button
            key="button"
            layoutId={`popover-${uniqueId}`}
            className="flex h-9 items-center border-2 bg-background px-3"
            style={{
              borderRadius: 8,
            }}
            onClick={openMenu}
          >
            <motion.span
              layoutId={`popover-label-${uniqueId}`}
              className="text-sm"
            >
              Feedback
            </motion.span>
          </motion.button>

          <AnimatePresence>
            {isOpen && (
              <motion.div
                ref={formContainerRef}
                layoutId={`popover-${uniqueId}`}
                className="absolute h-[200px] w-96 overflow-hidden   bg-muted-foreground/20 border-2 p-1"
                style={{
                  borderRadius: 12,
                }}
              >
                <form
                  className="flex h-full flex-col bg-background rounded-lg border-2 border-zinc-950/10"
                  onSubmit={(e) => {
                    e.preventDefault();
                  }}
                >
                  <motion.span
                    layoutId={`popover-label-${uniqueId}`}
                    aria-hidden="true"
                    style={{
                      opacity: feedback ? 0 : 1,
                    }}
                    className="absolute left-[21px] top-4 select-none text-sm text-zinc-500"
                  >
                    Feedback
                  </motion.span>
                  <textarea
                    className="h-full w-full resize-none rounded-md bg-transparent px-4 py-3 text-sm outline-none"
                    autoFocus
                    onChange={(e) => setFeedback(e.target.value)}
                  />
                  <div
                    key="close"
                    className="flex justify-end  p-3 border-t-2 border-dashed border-spacing-96  border-zinc-950/10"
                    style={{
                      borderSpacing: 10,
                    }}
                  >
                    <Button onClick={closeMenu}>
                      {isSending ? (
                        <Loader className="size-4 animate-spin" />
                      ) : (
                        "Send Feedback"
                      )}
                    </Button>
                  </div>
                </form>
                <AnimatePresence>
                  {isSent && (
                    <motion.div
                      className="absolute inset-0 bg-background flex items-center justify-center flex-col gap-1"
                      initial={{ opacity: 0, y: -100 }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={{ opacity: 0, y: -100 }}
                    >
                      <CheckCircle2 className="text-neutral-950 fill-neutral-300 size-5" />
                      <h2 className="text-sm text-neutral-950 font-medium ">
                        Feedback sent
                      </h2>
                      <p className="text-xs text-neutral-500">
                        Thanks for helping me improve graound.
                      </p>
                    </motion.div>
                  )}
                </AnimatePresence>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </MotionConfig>
    </div>
  );
};

const Button = ({
  children,
  onClick,
  disabled,
}: {
  children: React.ReactNode;
  onClick: () => void;
  disabled?: boolean;
}) => {
  return (
    <motion.button
      className="relative ml-1 flex h-8 text-sm text-neutral-100 bg-neutral-900 hover:bg-neutral-800 items-center justify-center px-3 rounded-md w-40"
      type="submit"
      aria-label={children as string}
      onClick={onClick}
      disabled={disabled}
      key={children as string}
      transition={TRANSITION}
    >
      {children}
    </motion.button>
  );
};

export default Feedback;