"use client";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type ComponentProps,
type ComponentType,
type CSSProperties,
type ReactNode,
} from "react";
import {
AnimatePresence,
MotionConfig,
motion,
useReducedMotion,
type Transition,
} from "motion/react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export function morphTransition(stiffness: number, damping: number): Transition {
return { type: "spring", stiffness, damping, mass: 0.8 };
}
const FOCUSABLE_SELECTOR =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
export interface MorphPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
layoutId?: string;
stiffness?: number;
damping?: number;
radius?: number;
blurBackdrop?: boolean;
autoFocus?: boolean;
backdropClassName?: string;
style?: CSSProperties;
onAnimationComplete?: () => void;
className?: string;
children: ReactNode;
reducedMotion?: boolean;
role?: "dialog" | "alertdialog";
"aria-label"?: string;
"aria-describedby"?: string;
}
export function MorphPanel({
open,
onOpenChange,
layoutId,
stiffness = 320,
damping = 34,
radius = 16,
blurBackdrop = true,
autoFocus = true,
backdropClassName,
style,
onAnimationComplete,
className,
children,
reducedMotion = false,
role = "dialog",
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedby,
}: MorphPanelProps) {
const [mounted, setMounted] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const openerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
useEffect(() => {
if (open) openerRef.current = document.activeElement as HTMLElement | null;
}, [open]);
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
const dialogs = document.querySelectorAll(
'[role="dialog"][aria-modal="true"]',
);
if (Array.from(dialogs).some((node) => node !== containerRef.current)) {
return;
}
event.preventDefault();
onOpenChange(false);
return;
}
if (event.key !== "Tab") return;
const focusables =
containerRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
if (!focusables || focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onOpenChange]);
useEffect(() => {
if (!open) return;
document.body.classList.add("overflow-hidden");
if (autoFocus) {
containerRef.current
?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)?.[0]
?.focus();
}
return () => document.body.classList.remove("overflow-hidden");
}, [open, autoFocus]);
useEffect(() => {
if (!open) openerRef.current?.focus?.();
}, [open]);
const transition = useMemo(
() => morphTransition(stiffness, damping),
[stiffness, damping],
);
if (!mounted) return null;
const morph = !reducedMotion && Boolean(layoutId);
const fade = {
initial: { opacity: 0, scale: 0.96 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.96 },
};
return createPortal(
<MotionConfig transition={reducedMotion ? { duration: 0 } : transition}>
<AnimatePresence initial={false} mode="sync">
{open && (
<>
<motion.div
key="morph-backdrop"
className={cn(
"fixed inset-0 z-40 h-full w-full",
backdropClassName ??
cn(
"bg-white/40 dark:bg-black/50",
blurBackdrop && "backdrop-blur-xs",
),
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => onOpenChange(false)}
/>
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
ref={containerRef}
layoutId={morph ? layoutId : undefined}
initial={morph ? false : fade.initial}
animate={morph ? undefined : fade.animate}
exit={morph ? undefined : fade.exit}
style={{ borderRadius: radius, ...style }}
onAnimationComplete={onAnimationComplete}
role={role}
aria-modal="true"
aria-label={ariaLabel}
aria-describedby={ariaDescribedby}
className={cn("pointer-events-auto overflow-hidden", className)}
>
{children}
</motion.div>
</div>
</>
)}
</AnimatePresence>
</MotionConfig>,
document.body,
);
}
type MorphTag = "div" | "span" | "h2" | "h3" | "p";
export type MorphSharedProps<T extends MorphTag = "div"> = {
as?: T;
layoutId?: string;
reducedMotion?: boolean;
} & Omit<ComponentProps<T>, "ref">;
export function MorphShared<T extends MorphTag = "div">({
as,
layoutId,
reducedMotion = false,
children,
...rest
}: MorphSharedProps<T>) {
const tag = (as ?? "div") as MorphTag;
if (reducedMotion || !layoutId) {
const Plain = tag as unknown as ComponentType<Record<string, unknown>>;
return <Plain {...rest}>{children}</Plain>;
}
const MotionTag = motion[tag] as ComponentType<Record<string, unknown>>;
return (
<MotionTag layoutId={layoutId} {...rest}>
{children}
</MotionTag>
);
}
export interface MorphDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: ReactNode;
children: ReactNode;
className?: string;
layoutId?: string;
stiffness?: number;
damping?: number;
radius?: number;
blurBackdrop?: boolean;
autoFocus?: boolean;
reducedMotion?: boolean;
role?: "dialog" | "alertdialog";
}
export default function MorphDialog({
open,
onOpenChange,
title,
description,
children,
className,
layoutId,
stiffness = 320,
damping = 34,
radius = 16,
blurBackdrop = true,
autoFocus = true,
reducedMotion,
role = "dialog",
}: MorphDialogProps) {
const osReduce = useReducedMotion();
const reduce = Boolean(osReduce) || Boolean(reducedMotion);
const descId = useId();
return (
<MorphPanel
open={open}
onOpenChange={onOpenChange}
layoutId={layoutId}
stiffness={stiffness}
damping={damping}
radius={radius}
blurBackdrop={blurBackdrop}
autoFocus={autoFocus}
reducedMotion={reduce}
role={role}
aria-label={title}
aria-describedby={description ? descId : undefined}
className={cn(
"flex max-h-[85svh] w-full max-w-sm flex-col overflow-hidden border border-hairline bg-panel shadow-2xl",
className,
)}
>
<motion.div
className="flex min-h-0 flex-1 flex-col"
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={reduce ? undefined : { duration: 0.18, delay: 0.12 }}
>
<div className="flex items-start justify-between gap-3 px-5 pt-4 pb-3">
<div className="flex flex-col gap-1">
<h2 className="font-display text-sm text-ink">{title}</h2>
{description && (
<p id={descId} className="font-sans text-xs leading-relaxed text-ink-mute">
{description}
</p>
)}
</div>
<button
type="button"
onClick={() => onOpenChange(false)}
className="-mt-0.5 -mr-1.5 rounded-md p-1.5 text-ink-dim transition-colors hover:bg-raised hover:text-ink"
aria-label="Close dialog"
>
<X className="size-4" />
</button>
</div>
{children}
</motion.div>
</MorphPanel>
);
}