"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion, type PanInfo } from "motion/react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface AnimatedModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
description?: React.ReactNode;
children?: React.ReactNode;
/** Action row (buttons) pinned to the bottom. */
footer?: React.ReactNode;
/** "auto" = centred dialog on wide screens, bottom sheet below `sheetBreakpoint`. */
variant?: "auto" | "dialog" | "sheet";
/** Width in px under which "auto" switches to the bottom sheet. */
sheetBreakpoint?: number;
size?: "sm" | "md" | "lg";
/** Close when the backdrop is clicked. */
dismissible?: boolean;
/** Element to focus when opened (defaults to the first focusable element). */
initialFocus?: React.RefObject<HTMLElement | null>;
showClose?: boolean;
className?: string;
}
const FOCUSABLE =
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
function useMediaQuery(query: string) {
const subscribe = React.useCallback(
(cb: () => void) => {
const mq = window.matchMedia(query);
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
},
[query],
);
return React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);
}
const widths = { sm: "sm:max-w-sm", md: "sm:max-w-md", lg: "sm:max-w-lg" };
export function AnimatedModal({
open,
onOpenChange,
title,
description,
children,
footer,
variant = "auto",
sheetBreakpoint = 640,
size = "md",
dismissible = true,
initialFocus,
showClose = true,
className,
}: AnimatedModalProps) {
const reduce = useReducedMotion();
const narrow = useMediaQuery(`(max-width: ${sheetBreakpoint - 0.02}px)`);
const sheet = variant === "sheet" || (variant === "auto" && narrow);
const panelRef = React.useRef<HTMLDivElement>(null);
const titleId = React.useId();
const descId = React.useId();
const close = React.useCallback(() => onOpenChange(false), [onOpenChange]);
// Remember what had focus, move focus in, lock scroll; undo all of it on close.
React.useEffect(() => {
if (!open) return;
const previous = document.activeElement as HTMLElement | null;
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
const id = requestAnimationFrame(() => {
const panel = panelRef.current;
// Prefer the first control in the body (e.g. an input) over the close button.
const target =
initialFocus?.current ??
panel?.querySelector<HTMLElement>(`[data-modal-body] :is(${FOCUSABLE})`) ??
panel?.querySelector<HTMLElement>(FOCUSABLE) ??
panel;
target?.focus({ preventScroll: true });
});
return () => {
cancelAnimationFrame(id);
document.body.style.overflow = overflow;
previous?.focus?.({ preventScroll: true });
};
}, [open, initialFocus]);
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.stopPropagation();
close();
return;
}
if (e.key !== "Tab" || !panelRef.current) return;
const items = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((el) => el.offsetParent !== null);
if (items.length === 0) {
e.preventDefault();
return;
}
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === panelRef.current)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
const onDragEnd = (_: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) => {
if (info.offset.y > 120 || info.velocity.y > 700) close();
};
const spring = { type: "spring", stiffness: 380, damping: 30, mass: 0.8 } as const;
const dialogMotion = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
: {
initial: { opacity: 0, scale: 0.9, y: 16, filter: "blur(10px)" },
animate: { opacity: 1, scale: 1, y: 0, filter: "blur(0px)" },
exit: { opacity: 0, scale: 0.95, y: 8, filter: "blur(6px)", transition: { duration: 0.18 } },
};
const sheetMotion = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
: { initial: { y: "100%" }, animate: { y: 0 }, exit: { y: "100%", transition: { duration: 0.25, ease: [0.4, 0, 1, 1] as const } } };
return (
<AnimatePresence>
{open && (
<div className={cn("fixed inset-0 z-50 flex justify-center", sheet ? "items-end" : "items-center p-4")} onKeyDown={onKeyDown}>
<motion.div
aria-hidden
className="absolute inset-0 bg-black/40 backdrop-blur-[3px]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.2 } }}
onClick={dismissible ? close : undefined}
/>
<motion.div
ref={panelRef}
key={sheet ? "sheet" : "dialog"}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descId : undefined}
tabIndex={-1}
{...(sheet ? sheetMotion : dialogMotion)}
transition={spring}
drag={sheet && !reduce ? "y" : false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0.05, bottom: 0.9 }}
onDragEnd={sheet ? onDragEnd : undefined}
className={cn(
"relative flex w-full flex-col bg-popover text-popover-foreground shadow-2xl outline-none",
sheet
? "max-h-[88dvh] rounded-t-3xl border-t pb-[max(1rem,env(safe-area-inset-bottom))]"
: cn("max-h-[calc(100dvh-2rem)] rounded-2xl border", widths[size]),
className,
)}
>
{sheet && (
<div className="flex cursor-grab justify-center pb-1 pt-3 active:cursor-grabbing" aria-hidden>
<span className="h-1.5 w-10 rounded-full bg-muted-foreground/30" />
</div>
)}
<div className={cn("flex items-start gap-4 px-6", sheet ? "pt-2" : "pt-6")}>
<div className="min-w-0 flex-1">
<h2 id={titleId} className="text-lg font-semibold tracking-tight">
{title}
</h2>
{description && (
<p id={descId} className="mt-1 text-sm text-muted-foreground">
{description}
</p>
)}
</div>
{showClose && (
<button
type="button"
onClick={close}
aria-label="Close"
className="-mr-2 -mt-1 rounded-full p-2 text-muted-foreground outline-none transition hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</button>
)}
</div>
{children && (
<div data-modal-body className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
{children}
</div>
)}
{footer && (
<div className={cn("flex flex-col-reverse gap-2 px-6 pb-6 sm:flex-row sm:justify-end", !children && "pt-4")}>{footer}</div>
)}
</motion.div>
</div>
)}
</AnimatePresence>
);
}