Survey Prompt
Feedback

Survey Prompt

Bottom-corner micro-survey that enters after a grace delay, pops the chosen rating, unfolds an optional comment box, and auto-dismisses its thanks state with a hover-and-hidden-tab-aware timer.

Install

npx shadcn@latest add @paragon/survey-prompt

Also installs: button

survey-prompt.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";

export interface SurveyPromptProps {
  question?: string;
  /** Numeric 1–5 chips or an emoji scale. */
  scale?: "numbers" | "emoji";
  /** ms after mount before the card enters. */
  delay?: number;
  /** Prompt above the expanded comment box. */
  followUp?: string;
  submitLabel?: string;
  thanksText?: string;
  /** Runs on submit with the rating (1–5) and optional comment. */
  onSubmit?: (rating: number, comment: string) => void;
  /** Runs when the user dismisses — persist it so the card never returns. */
  onDismiss?: () => void;
  /** ms the thanks state lingers before auto-dismissing. */
  thanksDuration?: number;
  className?: string;
}

const EMOJI = [
  { glyph: "😞", label: "Very dissatisfied" },
  { glyph: "😕", label: "Dissatisfied" },
  { glyph: "😐", label: "Neutral" },
  { glyph: "🙂", label: "Satisfied" },
  { glyph: "😍", label: "Very satisfied" },
];

/**
 * Bottom-corner micro-survey. Enters after a grace delay (never mid-task),
 * the pressed rating pops while its siblings stand still, a comment box
 * unfolds beneath via grid rows, and submission lands on a thanks state
 * that auto-dismisses — with the timer pausing on hover and on hidden tabs,
 * so nobody misses the acknowledgement. Dismissal is a single callback;
 * persist it and the prompt never nags again.
 */
export function SurveyPrompt({
  question = "How was your experience with the new dashboard?",
  scale = "numbers",
  delay = 800,
  followUp = "What could we improve?",
  submitLabel = "Send feedback",
  thanksText = "Thanks — your feedback went straight to the team.",
  onSubmit,
  onDismiss,
  thanksDuration = 2400,
  className,
}: SurveyPromptProps) {
  const reducedMotion = useReducedMotion();
  const [mounted, setMounted] = React.useState(false);
  const [visible, setVisible] = React.useState(true);
  const [rating, setRating] = React.useState<number | null>(null);
  const [comment, setComment] = React.useState("");
  const [phase, setPhase] = React.useState<"ask" | "thanks">("ask");
  const textareaRef = React.useRef<HTMLTextAreaElement>(null);
  const commentId = React.useId();

  // Pausable auto-dismiss for the thanks state.
  const dismissTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
  const remaining = React.useRef(thanksDuration);
  const startedAt = React.useRef(0);
  const hovered = React.useRef(false);

  React.useEffect(() => {
    const timer = setTimeout(() => setMounted(true), delay);
    return () => clearTimeout(timer);
  }, [delay]);

  const clearDismiss = React.useCallback(() => {
    if (dismissTimer.current) clearTimeout(dismissTimer.current);
    dismissTimer.current = null;
  }, []);

  const dismiss = React.useCallback(() => {
    clearDismiss();
    setVisible(false);
    onDismiss?.();
  }, [clearDismiss, onDismiss]);

  const startDismissTimer = React.useCallback(() => {
    clearDismiss();
    startedAt.current = Date.now();
    dismissTimer.current = setTimeout(dismiss, Math.max(0, remaining.current));
  }, [clearDismiss, dismiss]);

  const pauseDismissTimer = React.useCallback(() => {
    if (!dismissTimer.current) return;
    remaining.current -= Date.now() - startedAt.current;
    clearDismiss();
  }, [clearDismiss]);

  React.useEffect(() => {
    const onVisibility = () => {
      if (phase !== "thanks") return;
      if (document.hidden) pauseDismissTimer();
      else if (!hovered.current) startDismissTimer();
    };
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, [phase, pauseDismissTimer, startDismissTimer]);

  React.useEffect(() => () => clearDismiss(), [clearDismiss]);

  const select = (value: number) => {
    setRating(value);
    // Focus the composer once the fold-out has begun.
    requestAnimationFrame(() => textareaRef.current?.focus());
  };

  const submit = () => {
    if (rating === null) return;
    onSubmit?.(rating, comment.trim());
    setPhase("thanks");
    remaining.current = thanksDuration;
    if (!document.hidden && !hovered.current) startDismissTimer();
  };

  const pop = (value: number) =>
    reducedMotion || rating !== value
      ? { scale: 1 }
      : { scale: [1, 1.18, 1] as number[] };

  return (
    <AnimatePresence>
      {mounted && visible && (
        <motion.section
          aria-label="Feedback survey"
          initial={
            reducedMotion
              ? { opacity: 0 }
              : { opacity: 0, y: 16, filter: "blur(4px)" }
          }
          animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
          exit={{
            opacity: 0,
            transition: { duration: 0.15, ease: [0.4, 0, 1, 1] },
          }}
          transition={{ type: "spring", duration: 0.4, bounce: 0 }}
          onMouseEnter={() => {
            hovered.current = true;
            pauseDismissTimer();
          }}
          onMouseLeave={() => {
            hovered.current = false;
            if (phase === "thanks" && !document.hidden) startDismissTimer();
          }}
          className={cn(
            "w-80 rounded-xl bg-popover p-4 text-popover-foreground shadow-overlay",
            className,
          )}
        >
          {phase === "ask" ? (
            <>
              <div className="flex items-start gap-3">
                <p className="min-w-0 flex-1 text-sm leading-5 font-medium">
                  {question}
                </p>
                <button
                  type="button"
                  aria-label="Dismiss survey"
                  onClick={dismiss}
                  className="pressable relative -m-1 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 outline-none after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <X className="size-3.5" />
                </button>
              </div>

              <div
                role="group"
                aria-label="Rating, 1 to 5"
                className="mt-3 flex items-center justify-between gap-1.5"
              >
                {[1, 2, 3, 4, 5].map((value) => (
                  <motion.button
                    key={value}
                    type="button"
                    aria-pressed={rating === value}
                    aria-label={
                      scale === "emoji"
                        ? EMOJI[value - 1]!.label
                        : `Rate ${value} of 5`
                    }
                    onClick={() => select(value)}
                    animate={pop(value)}
                    transition={{ duration: 0.3, ease: [0.34, 1.36, 0.64, 1] }}
                    className={cn(
                      "relative flex h-9 flex-1 items-center justify-center rounded-lg outline-none",
                      "transition-[background-color,box-shadow,opacity,scale] duration-150 ease-[var(--ease-out)]",
                      "after:absolute after:top-1/2 after:left-1/2 after:h-10 after:w-full after:-translate-x-1/2 after:-translate-y-1/2",
                      "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover",
                      "active:not-disabled:scale-[0.97]",
                      scale === "emoji"
                        ? cn(
                            "text-lg",
                            rating === value
                              ? "bg-accent shadow-border"
                              : "hover:bg-accent/60",
                            rating !== null &&
                              rating !== value &&
                              "opacity-45 saturate-0",
                          )
                        : cn(
                            "text-sm font-medium tabular-nums",
                            rating === value
                              ? "bg-primary text-primary-foreground"
                              : "bg-secondary text-secondary-foreground hover:bg-accent",
                            rating !== null &&
                              rating !== value &&
                              "opacity-45",
                          ),
                    )}
                  >
                    {scale === "emoji" ? EMOJI[value - 1]!.glyph : value}
                  </motion.button>
                ))}
              </div>

              <div
                className={cn(
                  "grid transition-[grid-template-rows,opacity] duration-250 ease-[var(--ease-out)] motion-reduce:transition-[opacity]",
                  rating !== null
                    ? "grid-rows-[1fr] opacity-100"
                    : "grid-rows-[0fr] opacity-0",
                )}
              >
                <div
                  className="overflow-hidden"
                  inert={rating !== null ? undefined : true}
                >
                  <div className="pt-3">
                    <label
                      htmlFor={commentId}
                      className="text-xs font-medium text-muted-foreground"
                    >
                      {followUp}
                    </label>
                    <textarea
                      ref={textareaRef}
                      id={commentId}
                      rows={2}
                      value={comment}
                      onChange={(event) => setComment(event.target.value)}
                      placeholder="Optional, but it helps"
                      className="mt-1.5 w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm transition-[border-color,box-shadow] duration-150 ease-out outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25"
                    />
                    <Button size="sm" className="mt-2 w-full" onClick={submit}>
                      {submitLabel}
                    </Button>
                  </div>
                </div>
              </div>
            </>
          ) : (
            <div role="status" className="flex items-center gap-3 py-1">
              <motion.span
                initial={
                  reducedMotion
                    ? { opacity: 0 }
                    : { opacity: 0, scale: 0.25, filter: "blur(4px)" }
                }
                animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                transition={{ type: "spring", duration: 0.35, bounce: 0.15 }}
                aria-hidden
                className="flex size-8 shrink-0 items-center justify-center rounded-full bg-success/10 text-success"
              >
                <Check className="size-4" />
              </motion.span>
              <p className="min-w-0 flex-1 text-sm leading-5">{thanksText}</p>
            </div>
          )}
        </motion.section>
      )}
    </AnimatePresence>
  );
}