"use client";
import * as React from "react";
import { animate, type AnimationPlaybackControls } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
export interface AnimatedDialogProps {
/** Controlled open state. */
open?: boolean;
/** Initial open state when uncontrolled. */
defaultOpen?: boolean;
/** Called whenever the dialog asks to open or close (trigger, close button, Escape, backdrop). */
onOpenChange?: (open: boolean) => void;
/** Element that opens the dialog (usually a button). It gets onClick and aria-haspopup / aria-expanded. */
trigger?: React.ReactElement<React.HTMLAttributes<HTMLElement>>;
title: React.ReactNode;
description?: React.ReactNode;
/** Dialog body. */
children?: React.ReactNode;
/** Actions row; `AnimatedDialogClose` buttons inside it close the dialog. */
footer?: React.ReactNode;
size?: "sm" | "md" | "lg";
/** "alertdialog" for confirmations: focus starts on the least destructive action. */
role?: "dialog" | "alertdialog";
closeOnEscape?: boolean;
closeOnBackdrop?: boolean;
/** Show the × button in the header. */
showClose?: boolean;
/** Backdrop blur in px. */
blur?: number;
/** Spring for the scale-in from the trigger. */
spring?: Spring;
/** Element to focus when the dialog opens (otherwise `[data-autofocus]`, then a sensible default). */
initialFocus?: React.RefObject<HTMLElement | null>;
/** Focus ring colour. */
accent?: string;
/** Extra classes for the panel. */
className?: string;
}
const SIZES = { sm: 400, md: 520, lg: 680 } as const;
const FOCUSABLE = 'a[href], button:not(:disabled), input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])';
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* Scroll lock shared by every open dialog (nested dialogs lock once). */
let lockCount = 0;
let saved = { overflow: "", paddingRight: "" };
function lockScroll() {
if (lockCount++ > 0) return;
const root = document.documentElement;
saved = { overflow: root.style.overflow, paddingRight: root.style.paddingRight };
const scrollbar = window.innerWidth - root.clientWidth;
// Keep the layout from jumping when the scrollbar disappears (unless a gutter is already reserved).
if (scrollbar > 0 && !getComputedStyle(root).scrollbarGutter?.includes("stable")) {
root.style.paddingRight = `${parseFloat(getComputedStyle(root).paddingRight) + scrollbar}px`;
}
root.style.overflow = "hidden";
}
function unlockScroll() {
if (lockCount === 0 || --lockCount > 0) return;
const root = document.documentElement;
root.style.overflow = saved.overflow;
root.style.paddingRight = saved.paddingRight;
}
const DialogContext = React.createContext<{ close: () => void } | null>(null);
/** A button that closes the nearest AnimatedDialog. Put your Cancel / Done actions in it. */
export function AnimatedDialogClose({ onClick, type = "button", ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
const ctx = React.useContext(DialogContext);
return (
<button
type={type}
data-dialog-close=""
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) ctx?.close();
}}
{...props}
/>
);
}
/**
* A modal dialog on the native <dialog> element (top layer, inert page, focus trapping by the
* browser). It scales in from the trigger that opened it over a blurred, fading backdrop and
* shrinks back towards it on close. Escape and backdrop clicks are configurable, `alertdialog`
* focuses the least destructive action, focus returns to the trigger, page scroll is locked and
* dialogs can be nested. With reduced motion it only fades.
*/
export function AnimatedDialog({
open: openProp,
defaultOpen = false,
onOpenChange,
trigger,
title,
description,
children,
footer,
size = "md",
role = "dialog",
closeOnEscape = true,
closeOnBackdrop = true,
showClose = true,
blur = 6,
spring = { stiffness: 380, damping: 30, mass: 0.8 },
initialFocus,
accent = "#FF7448",
className = "",
}: AnimatedDialogProps) {
const uid = React.useId();
const reduceMotion = usePrefersReducedMotion();
const [internal, setInternal] = React.useState(defaultOpen);
const isOpen = openProp ?? internal;
const dialogRef = React.useRef<HTMLDialogElement>(null);
const panelRef = React.useRef<HTMLDivElement>(null);
const backdropRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLElement | null>(null);
const returnFocus = React.useRef<HTMLElement | null>(null);
const locked = React.useRef(false);
const closing = React.useRef(false);
const animations = React.useRef<AnimationPlaybackControls[]>([]);
const pressStartedOnBackdrop = React.useRef(false);
const setOpen = (next: boolean) => {
if (openProp === undefined) setInternal(next);
onOpenChange?.(next);
};
React.useLayoutEffect(() => {
const dialog = dialogRef.current;
const panel = panelRef.current;
const backdrop = backdropRef.current;
if (!dialog || !panel || !backdrop) return;
const stop = () => {
animations.current.forEach((a) => a.stop());
animations.current = [];
};
if (isOpen) {
if (dialog.open && !closing.current) return;
closing.current = false;
stop();
if (!dialog.open) {
returnFocus.current = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null;
dialog.showModal();
if (!locked.current) {
lockScroll();
locked.current = true;
}
}
// Scale in from the trigger: put the transform origin at its centre, in panel coordinates.
const anchor = triggerRef.current ?? returnFocus.current;
panel.style.transform = "none";
const p = panel.getBoundingClientRect();
if (anchor && anchor.isConnected) {
const a = anchor.getBoundingClientRect();
panel.style.transformOrigin = `${Math.round(a.left + a.width / 2 - p.left)}px ${Math.round(a.top + a.height / 2 - p.top)}px`;
} else {
panel.style.transformOrigin = "50% 50%";
}
animations.current = reduceMotion
? [animate(panel, { opacity: [0, 1] }, { duration: 0.16 }), animate(backdrop, { opacity: [0, 1] }, { duration: 0.16 })]
: [
animate(panel, { opacity: [0, 1], scale: [0.35, 1] }, { type: "spring", ...spring, opacity: { duration: 0.18, ease: "easeOut" } }),
animate(backdrop, { opacity: [0, 1], backdropFilter: ["blur(0px)", `blur(${blur}px)`] }, { duration: 0.28, ease: "easeOut" }),
];
if (reduceMotion) backdrop.style.backdropFilter = `blur(${blur}px)`;
// Initial focus: explicit → [data-autofocus] → (alertdialog) the first close action → first control → panel.
// Only this dialog's own elements count, not those of a nested dialog inside its body.
const find = (selector: string) => Array.from(panel.querySelectorAll<HTMLElement>(selector)).find((el) => el.closest("dialog") === dialog);
const explicit = initialFocus?.current ?? find("[data-autofocus]");
const fallback =
role === "alertdialog"
? (find("[data-dialog-footer] [data-dialog-close]") ?? find("[data-dialog-footer] button"))
: find(`[data-dialog-content] :is(${FOCUSABLE}), [data-dialog-footer] :is(${FOCUSABLE})`);
(explicit ?? fallback ?? panel).focus({ preventScroll: true });
return;
}
if (!dialog.open || closing.current) return;
closing.current = true;
stop();
const out = reduceMotion
? [animate(panel, { opacity: 0 }, { duration: 0.12 }), animate(backdrop, { opacity: 0 }, { duration: 0.12 })]
: [
animate(panel, { opacity: 0, scale: 0.6 }, { duration: 0.18, ease: [0.4, 0, 1, 1] }),
animate(backdrop, { opacity: 0, backdropFilter: "blur(0px)" }, { duration: 0.2, ease: "easeIn" }),
];
animations.current = out;
void Promise.all(out.map((a) => a.finished)).then(() => {
if (closing.current && dialog.open) dialog.close();
});
}, [isOpen, reduceMotion, spring, blur, role, initialFocus]);
// Unmounting while open: stop animations, close the dialog and release the scroll lock.
React.useEffect(() => {
const dialog = dialogRef.current;
return () => {
animations.current.forEach((a) => a.stop());
if (dialog?.open) dialog.close();
if (locked.current) {
unlockScroll();
locked.current = false;
}
};
}, []);
const requestClose = () => setOpen(false);
const triggerElement = trigger
? React.cloneElement(trigger, {
"aria-haspopup": "dialog",
"aria-expanded": isOpen,
"aria-controls": isOpen ? `${uid}-dialog` : undefined,
onClick: (event: React.MouseEvent<HTMLElement>) => {
trigger.props.onClick?.(event);
if (event.defaultPrevented) return;
triggerRef.current = event.currentTarget;
setOpen(true);
},
})
: null;
return (
<>
{triggerElement}
<dialog
ref={dialogRef}
id={`${uid}-dialog`}
role={role === "alertdialog" ? "alertdialog" : undefined}
aria-labelledby={`${uid}-title`}
aria-describedby={description ? `${uid}-desc` : undefined}
onCancel={(event) => {
// Nested dialogs: React bubbles this through the component tree; only handle our own.
if (event.target !== event.currentTarget) return;
event.preventDefault();
if (closeOnEscape) requestClose();
}}
onClose={(event) => {
if (event.target !== event.currentTarget) return;
closing.current = false;
animations.current.forEach((a) => a.stop());
if (locked.current) {
unlockScroll();
locked.current = false;
}
// The browser can force-close (e.g. repeated Escape); keep state in sync.
if (isOpen) setOpen(false);
const target = returnFocus.current;
returnFocus.current = null;
triggerRef.current = null;
if (target?.isConnected && (document.activeElement === document.body || document.activeElement === null)) target.focus({ preventScroll: true });
}}
className="fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden border-0 bg-transparent p-0 text-left backdrop:bg-transparent"
style={{ ["--ad-accent" as string]: accent }}
>
<div ref={backdropRef} aria-hidden="true" className="absolute inset-0 bg-zinc-950/35 opacity-0 dark:bg-black/55" />
<div
className="relative flex h-full w-full items-center justify-center overflow-y-auto p-4"
onPointerDown={(event) => {
pressStartedOnBackdrop.current = event.target === event.currentTarget;
}}
onClick={(event) => {
// Only a press that starts and ends outside the panel counts (drags out of inputs don't close).
if (event.target === event.currentTarget && pressStartedOnBackdrop.current && closeOnBackdrop) requestClose();
pressStartedOnBackdrop.current = false;
}}
>
<div
ref={panelRef}
tabIndex={-1}
className={`relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden rounded-2xl border border-zinc-900/10 bg-white text-zinc-900 opacity-0 shadow-[0_32px_80px_-24px_rgb(0_0_0/0.45),0_2px_8px_-2px_rgb(0_0_0/0.08)] outline-hidden dark:border-white/10 dark:bg-[#16191c] dark:text-zinc-100 dark:shadow-[0_32px_80px_-20px_rgb(0_0_0/0.9)] ${className}`}
style={{ maxWidth: SIZES[size] ?? SIZES.md }}
>
<DialogContext.Provider value={{ close: requestClose }}>
<div className="flex items-start gap-4 px-6 pb-1 pt-5">
<div className="min-w-0 flex-1">
<h2 id={`${uid}-title`} className="text-[17px] font-semibold leading-snug tracking-[-0.015em]">
{title}
</h2>
{description ? (
<p id={`${uid}-desc`} className="mt-1 text-[13px] leading-relaxed text-zinc-500 dark:text-zinc-400">
{description}
</p>
) : null}
</div>
{showClose ? (
<button
type="button"
aria-label="Close"
onClick={requestClose}
className="-mr-2 -mt-1 grid size-8 shrink-0 place-items-center rounded-lg text-zinc-500 outline-hidden transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--ad-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.07] dark:hover:text-white"
>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-4">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
) : null}
</div>
{children ? (
<div data-dialog-content="" className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 pb-5 pt-3 text-[14px] leading-relaxed text-zinc-600 dark:text-zinc-300">
{children}
</div>
) : (
<div className="h-4" />
)}
{footer ? (
<div data-dialog-footer="" className="flex flex-wrap items-center justify-end gap-2 border-t border-zinc-900/[0.07] bg-zinc-50/70 px-6 py-3.5 dark:border-white/[0.07] dark:bg-white/[0.02]">
{footer}
</div>
) : null}
</DialogContext.Provider>
</div>
</div>
</dialog>
</>
);
}