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.
npx shadcn@latest add @paragon/survey-promptAlso installs: button
"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>
);
}