Back to components

Feedback Popover

A Motion-powered feedback popover that expands from a trigger, supports keyboard submission, and confirms receipt inline.

overlayexpand
"use client";

import * as React from "react";
import { CheckCircle2 } from "lucide-react";
import {
  AnimatePresence,
  LayoutGroup,
  motion,
  useReducedMotion,
} from "motion/react";

import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type FeedbackState = "idle" | "loading" | "success";

const INSTANT_TRANSITION = { duration: 0 } as const;
const CONTENT_TRANSITION = {
  type: "spring",
  duration: 0.4,
  bounce: 0,
} as const;
const SUBMIT_TRANSITION = {
  type: "spring",
  duration: 0.3,
  bounce: 0,
} as const;

export type FeedbackPopoverProps = Omit<
  React.ComponentProps<"div">,
  "children" | "onSubmit"
> & {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  onSubmit?: (feedback: string) => void | Promise<void>;
  /**
   * Called when `onSubmit` rejects. Without this the form silently returns to
   * idle with the draft intact and no way to surface the failure.
   */
  onError?: (error: unknown) => void;
  triggerLabel?: React.ReactNode;
  /**
   * Doubles as the placeholder: it floats over the empty textarea and clears
   * once there is text, so there is no separate `placeholder` prop.
   */
  textareaLabel?: string;
  submitLabel?: React.ReactNode;
  successTitle?: React.ReactNode;
  successDescription?: React.ReactNode;
  loadingAnnouncement?: string;
  /**
   * Minimum time in ms the loading state stays visible, so quick submissions
   * don't flash. Slow ones keep loading until `onSubmit` settles.
   */
  loadingDuration?: number;
  /** Time in ms the success screen stays visible before the popover closes. */
  successDuration?: number;
};

function Spinner({ className }: { className?: string }) {
  return (
    <span
      aria-hidden="true"
      className={cn(
        "size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent opacity-70 motion-reduce:animate-none",
        className,
      )}
    />
  );
}

function wait(duration: number) {
  return new Promise((resolve) => {
    window.setTimeout(resolve, duration);
  });
}

export function FeedbackPopover({
  open,
  defaultOpen = false,
  onOpenChange,
  onSubmit,
  onError,
  triggerLabel = "Feedback",
  textareaLabel = "Feedback",
  submitLabel = "Send feedback",
  successTitle = "Feedback received!",
  successDescription = "Thanks for helping us improve.",
  loadingAnnouncement = "Sending feedback",
  loadingDuration = 1500,
  successDuration = 1800,
  className,
  ...props
}: FeedbackPopoverProps) {
  const reactId = React.useId();
  const titleId = `${reactId}-title`;
  const descriptionId = `${reactId}-description`;
  const popoverRef = React.useRef<HTMLDivElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);
  const timers = React.useRef<number[]>([]);
  const isMountedRef = React.useRef(true);
  const shouldReduceMotion = useReducedMotion();
  const isControlled = open !== undefined;
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
  const [feedback, setFeedback] = React.useState("");
  const [formState, setFormState] = React.useState<FeedbackState>("idle");
  const isOpen = isControlled ? open : uncontrolledOpen;
  const trimmedFeedback = feedback.trim();

  const clearTimers = React.useCallback(() => {
    timers.current.forEach(window.clearTimeout);
    timers.current = [];
  }, []);

  const setOpen = React.useCallback(
    (nextOpen: boolean) => {
      if (!isControlled) {
        setUncontrolledOpen(nextOpen);
      }

      onOpenChange?.(nextOpen);
    },
    [isControlled, onOpenChange],
  );

  const closePopover = React.useCallback(
    (options?: { restoreFocus?: boolean }) => {
      clearTimers();
      setOpen(false);

      // Keyboard and programmatic closes return focus to the trigger so the
      // user isn't dropped on <body>; pointer dismissals leave focus where
      // the user clicked.
      if (options?.restoreFocus) {
        triggerRef.current?.focus();
      }
    },
    [clearTimers, setOpen],
  );

  const openPopover = React.useCallback(() => {
    clearTimers();
    setFormState("idle");
    setFeedback("");
    setOpen(true);
  }, [clearTimers, setOpen]);

  // Controlled parents can flip `open` without going through `openPopover`;
  // reset on the closed -> open transition so a reopen never shows the
  // previous session's stale draft or success screen.
  const wasOpenRef = React.useRef(isOpen);

  React.useEffect(() => {
    const wasOpen = wasOpenRef.current;
    wasOpenRef.current = isOpen;

    if (isOpen && !wasOpen) {
      clearTimers();
      setFormState("idle");
      setFeedback("");
    }
  }, [clearTimers, isOpen]);

  const submitFeedback = React.useCallback(async () => {
    if (!trimmedFeedback || formState !== "idle") return;

    clearTimers();
    setFormState("loading");

    try {
      await Promise.all([onSubmit?.(trimmedFeedback), wait(loadingDuration)]);
    } catch (error) {
      if (!isMountedRef.current) return;
      setFormState("idle");
      onError?.(error);
      return;
    }

    if (!isMountedRef.current) return;

    setFormState("success");
    timers.current = [
      window.setTimeout(() => {
        setOpen(false);
        triggerRef.current?.focus();
      }, successDuration),
    ];
  }, [
    clearTimers,
    formState,
    loadingDuration,
    onError,
    onSubmit,
    setOpen,
    successDuration,
    trimmedFeedback,
  ]);

  React.useEffect(() => {
    isMountedRef.current = true;
    return () => {
      isMountedRef.current = false;
      clearTimers();
    };
  }, [clearTimers]);

  React.useEffect(() => {
    if (!isOpen) return;

    const handlePointerDown = (event: PointerEvent) => {
      const popover = popoverRef.current;

      if (!popover || popover.contains(event.target as Node)) return;

      closePopover();
    };

    document.addEventListener("pointerdown", handlePointerDown);
    return () => document.removeEventListener("pointerdown", handlePointerDown);
  }, [closePopover, isOpen]);

  React.useEffect(() => {
    if (!isOpen) return;

    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        closePopover({ restoreFocus: true });
        return;
      }

      if (
        (event.ctrlKey || event.metaKey) &&
        event.key === "Enter" &&
        formState === "idle" &&
        // Only submit when focus is inside the popover, so Cmd/Ctrl+Enter in
        // some other form on the page doesn't send this one.
        popoverRef.current?.contains(document.activeElement)
      ) {
        event.preventDefault();
        void submitFeedback();
      }
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [closePopover, formState, isOpen, submitFeedback]);

  // Match the reference: the popover morph uses Motion's default layout transition
  // (tween, 0.45s, ease [0.4, 0, 0.1, 1]) by leaving `transition` undefined.
  const layoutTransition = shouldReduceMotion ? INSTANT_TRANSITION : undefined;
  const contentTransition = shouldReduceMotion
    ? INSTANT_TRANSITION
    : CONTENT_TRANSITION;
  const submitTransition = shouldReduceMotion
    ? INSTANT_TRANSITION
    : SUBMIT_TRANSITION;

  return (
    <div
      data-slot="feedback-popover"
      className={cn(
        "relative inline-flex max-w-full [--feedback-popover-width:min(22rem,calc(100vw-2rem))]",
        className,
      )}
      {...props}
    >
      <LayoutGroup id={reactId}>
        <motion.button
          ref={triggerRef}
          type="button"
          layoutId="feedback-popover-wrapper"
          aria-haspopup="dialog"
          aria-expanded={isOpen}
          aria-controls={isOpen ? reactId : undefined}
          onClick={openPopover}
          transition={layoutTransition}
          style={{ borderRadius: 8 }}
          className={cn(
            buttonVariants({ variant: "outline", size: "default" }),
            "relative overflow-hidden transition-colors motion-reduce:transition-none",
          )}
        >
          <motion.span
            layoutId="feedback-popover-title"
            transition={layoutTransition}
            className="leading-6"
          >
            {triggerLabel}
          </motion.span>
        </motion.button>

        <AnimatePresence>
          {isOpen ? (
            <motion.div
              key="popover"
              id={reactId}
              ref={popoverRef}
              layoutId="feedback-popover-wrapper"
              role="dialog"
              aria-labelledby={titleId}
              aria-describedby={
                formState === "success" ? descriptionId : undefined
              }
              transition={layoutTransition}
              style={{ borderRadius: 12 }}
              className="absolute left-1/2 top-1/2 z-50 h-48 w-(--feedback-popover-width) -translate-x-1/2 -translate-y-1/2 overflow-hidden border bg-background text-foreground shadow-sm"
            >
              <motion.span
                id={titleId}
                layoutId="feedback-popover-title"
                transition={layoutTransition}
                data-state={formState}
                data-has-feedback={feedback ? "true" : "false"}
                className="pointer-events-none absolute left-3 top-3 text-base font-medium leading-6 text-muted-foreground data-[has-feedback=true]:opacity-0! data-[state=success]:opacity-0! sm:text-sm"
              >
                {textareaLabel}
              </motion.span>

              <AnimatePresence mode="popLayout" initial={false}>
                {formState === "success" ? (
                  <motion.div
                    key="success"
                    initial={
                      shouldReduceMotion
                        ? false
                        : {
                            opacity: 0,
                            transform: "translateY(-32px)",
                            filter: "blur(4px)",
                          }
                    }
                    animate={{
                      opacity: 1,
                      transform: "translateY(0px)",
                      filter: "blur(0px)",
                    }}
                    exit={
                      shouldReduceMotion
                        ? undefined
                        : {
                            opacity: 0,
                            transform: "translateY(8px)",
                            filter: "blur(4px)",
                          }
                    }
                    transition={contentTransition}
                    className="flex h-full flex-col items-center justify-center gap-3 px-8 py-8 text-center"
                  >
                    <span
                      aria-hidden="true"
                      className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary"
                    >
                      <CheckCircle2 className="size-6" />
                    </span>
                    <div className="flex flex-col gap-1">
                      <h3 className="text-base font-semibold">
                        {successTitle}
                      </h3>
                      <p
                        id={descriptionId}
                        className="text-sm leading-5 text-muted-foreground"
                      >
                        {successDescription}
                      </p>
                    </div>
                  </motion.div>
                ) : (
                  <motion.form
                    key="form"
                    exit={
                      shouldReduceMotion
                        ? undefined
                        : {
                            opacity: 0,
                            transform: "translateY(8px)",
                            filter: "blur(4px)",
                          }
                    }
                    transition={contentTransition}
                    onSubmit={(event) => {
                      event.preventDefault();
                      void submitFeedback();
                    }}
                    className="flex h-full flex-col"
                  >
                    <label htmlFor={`${reactId}-textarea`} className="sr-only">
                      {textareaLabel}
                    </label>
                    {/* 16px below `sm`: iOS Safari zooms the whole page in when
                        a smaller field takes focus, and `autoFocus` makes that
                        happen the moment the popover opens. */}
                    <textarea
                      id={`${reactId}-textarea`}
                      autoFocus
                      required
                      value={feedback}
                      disabled={formState === "loading"}
                      onChange={(event) => setFeedback(event.target.value)}
                      className="min-h-0 flex-1 resize-none bg-transparent px-3 pb-3 pt-3 text-base leading-6 outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-60 sm:text-sm"
                    />

                    <div className="relative flex items-center justify-end border-t border-dashed bg-muted/30 px-3 py-2.5">
                      <span
                        aria-hidden="true"
                        className="absolute -left-px top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border bg-background"
                      />
                      <span
                        aria-hidden="true"
                        className="absolute -right-px top-1/2 size-3 -translate-y-1/2 translate-x-1/2 rounded-full border bg-background"
                      />
                      <Button
                        type="submit"
                        size="sm"
                        disabled={!trimmedFeedback || formState === "loading"}
                        className="min-w-32 overflow-hidden"
                      >
                        <AnimatePresence mode="popLayout" initial={false}>
                          <motion.span
                            key={formState}
                            initial={
                              shouldReduceMotion
                                ? false
                                : {
                                    opacity: 0,
                                    transform: "translateY(-25px)",
                                  }
                            }
                            animate={{
                              opacity: 1,
                              transform: "translateY(0px)",
                            }}
                            exit={
                              shouldReduceMotion
                                ? undefined
                                : {
                                    opacity: 0,
                                    transform: "translateY(25px)",
                                  }
                            }
                            transition={submitTransition}
                            className="inline-flex items-center justify-center gap-1.5"
                          >
                            {formState === "loading" ? (
                              <>
                                <Spinner />
                                <span className="sr-only">
                                  {loadingAnnouncement}
                                </span>
                              </>
                            ) : (
                              <span>{submitLabel}</span>
                            )}
                          </motion.span>
                        </AnimatePresence>
                      </Button>
                    </div>
                  </motion.form>
                )}
              </AnimatePresence>
            </motion.div>
          ) : null}
        </AnimatePresence>
      </LayoutGroup>
    </div>
  );
}

Installation