Feedback Popover
A Motion-powered feedback popover that expands from a trigger, supports keyboard submission, and confirms receipt inline.
popoverexpand
"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";
export type FeedbackPopoverProps = Omit<
React.ComponentPropsWithoutRef<"div">,
"children" | "onSubmit"
> & {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
onSubmit?: (feedback: string) => void | Promise<void>;
triggerLabel?: React.ReactNode;
textareaLabel?: string;
placeholder?: string;
submitLabel?: React.ReactNode;
successTitle?: React.ReactNode;
successDescription?: React.ReactNode;
loadingAnnouncement?: string;
loadingDuration?: number;
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",
className,
)}
/>
);
}
function wait(duration: number) {
return new Promise((resolve) => {
window.setTimeout(resolve, duration);
});
}
export function FeedbackPopover({
open,
defaultOpen = false,
onOpenChange,
onSubmit,
triggerLabel = "Feedback",
textareaLabel = "Feedback",
placeholder = "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 timers = React.useRef<number[]>([]);
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(() => {
clearTimers();
setOpen(false);
}, [clearTimers, setOpen]);
const openPopover = React.useCallback(() => {
clearTimers();
setFormState("idle");
setFeedback("");
setOpen(true);
}, [clearTimers, setOpen]);
const submitFeedback = React.useCallback(async () => {
if (!trimmedFeedback || formState !== "idle") return;
clearTimers();
setFormState("loading");
try {
await Promise.all([onSubmit?.(trimmedFeedback), wait(loadingDuration)]);
} catch {
setFormState("idle");
return;
}
setFormState("success");
timers.current = [
window.setTimeout(() => {
setOpen(false);
}, successDuration),
];
}, [
clearTimers,
formState,
loadingDuration,
onSubmit,
setOpen,
successDuration,
trimmedFeedback,
]);
React.useEffect(() => {
return () => {
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();
return;
}
if (
(event.ctrlKey || event.metaKey) &&
event.key === "Enter" &&
formState === "idle"
) {
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 ? { duration: 0 } : undefined;
const contentTransition = shouldReduceMotion
? { duration: 0 }
: ({ type: "spring", duration: 0.4, bounce: 0 } as const);
const submitTransition = shouldReduceMotion
? { duration: 0 }
: ({ type: "spring", duration: 0.3, bounce: 0 } as const);
return (
<div
data-slot="feedback-popover"
className={cn("relative inline-flex", className)}
{...props}
>
<LayoutGroup id={reactId}>
<motion.button
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.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-[min(22rem,calc(100vw-2rem))] -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-sm font-medium leading-6 text-muted-foreground data-[has-feedback=true]:opacity-0! data-[state=success]:opacity-0!"
>
{textareaLabel}
</motion.span>
<AnimatePresence mode="popLayout" initial={false}>
{formState === "success" ? (
<motion.div
key="success"
initial={
shouldReduceMotion
? false
: { opacity: 0, y: -32, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
shouldReduceMotion
? undefined
: { opacity: 0, y: 8, 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, y: 8, 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>
<textarea
id={`${reactId}-textarea`}
autoFocus
required
value={feedback}
placeholder={placeholder}
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-sm leading-6 outline-none placeholder:text-transparent focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-60"
/>
<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, y: -25 }
}
animate={{ opacity: 1, y: 0 }}
exit={
shouldReduceMotion
? undefined
: { opacity: 0, y: 25 }
}
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
pnpm dlx shadcn@latest add https://ui.ericts.com/r/feedback-popover.json