"use client"; import { AnimatePresence, motion, useReducedMotion, } from "motion/react"; import { type ReactNode, useEffect, useLayoutEffect, useState } from "react"; import { createPortal } from "react-dom"; import { EASE_OUT, SPRING_PANEL } from "@/lib/ease"; import { PresenceGate } from "@/lib/presence-gate"; import { cn } from "@/lib/utils"; export interface MorphingModalProps { /** Which view is currently shown. `null` closes the modal. */ viewId: string | null; onClose: () => void; children: ReactNode; /** "bottom" anchors to the viewport bottom (mobile-like). "center" centers vertically. */ placement?: "bottom" | "center"; className?: string; } export function MorphingModal({ viewId, onClose, children, placement = "bottom", className, }: MorphingModalProps) { const open = viewId !== null; const reduce = useReducedMotion(); const enterY = reduce ? 0 : placement === "bottom" ? 40 : 20; const enterScale = reduce ? 1 : 0.97; const [portalReady, setPortalReady] = useState(false); const [backgroundScrollLocked, setBackgroundScrollLocked] = useState(open); useEffect(() => setPortalReady(true), []); if (open && !backgroundScrollLocked) setBackgroundScrollLocked(true); useLayoutEffect(() => { if (!backgroundScrollLocked) return; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; }, [backgroundScrollLocked]); if (!portalReady) return null; // Keep viewport coordinates independent of transformed/clipped consumers. // Fixed siblings retain the inset, non-interactive panel positioning layer. // CSS variables keep the final paint values in Motion's render loop. Motion // 11 cancels WAAPI before its final inline-style write, flashing the old value // for a frame in Chrome (including while an exiting sibling is still mounted). return createPortal( { if (!open) setBackgroundScrollLocked(false); }} > {open ? ( {({ gate }) => ( )} ) : null} {open ? ( {({ isPresent, gate }) => ( // The layer itself never takes pointer events, so it carries // `inert` alone rather than the gate's pointer-events value. {/* popLayout offsets must keep the same containing block while layout projection applies and removes transforms. */} {children} )} ) : null} , document.body, ); }