Installation
Install the following dependencies:
pnpm add motion
Create a file at components/motion/dialog.tsx
and paste the given code.
"use client";
import { AnimatePresence, motion, Transition, Variants } from "motion/react";
import React, { createContext, useContext, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { useId } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { usePreventScroll } from "@/hooks/use-prevent-scroll";
const DialogContext = createContext<{
isOpen: boolean;
setIsOpen: (open: boolean) => void;
dialogRef: React.RefObject<HTMLDialogElement | null>;
variants: Variants;
transition?: Transition;
ids: {
dialog: string;
title: string;
description: string;
};
onAnimationComplete: (definition: string) => void;
handleTrigger: () => void;
} | null>(null);
const defaultVariants: Variants = {
initial: {
opacity: 0,
scale: 0.9,
},
animate: {
opacity: 1,
scale: 1,
},
};
const defaultTransition: Transition = {
ease: "easeOut",
duration: 0.2,
};
export type DialogProps = {
children: React.ReactNode;
variants?: Variants;
transition?: Transition;
className?: string;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
open?: boolean;
};
function Dialog({
children,
variants = defaultVariants,
transition = defaultTransition,
defaultOpen,
onOpenChange,
open,
}: DialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
defaultOpen || false,
);
const dialogRef = useRef<HTMLDialogElement>(null);
const isOpen = open !== undefined ? open : uncontrolledOpen;
// prevent scroll when dialog is open on iOS
usePreventScroll({
isDisabled: !isOpen,
});
const setIsOpen = React.useCallback(
(value: boolean) => {
setUncontrolledOpen(value);
onOpenChange?.(value);
},
[onOpenChange],
);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
document.body.classList.add("overflow-hidden");
} else {
document.body.classList.remove("overflow-hidden");
}
const handleCancel = (e: Event) => {
e.preventDefault();
if (isOpen) {
setIsOpen(false);
}
};
dialog.addEventListener("cancel", handleCancel);
return () => {
dialog.removeEventListener("cancel", handleCancel);
document.body.classList.remove("overflow-hidden");
};
}, [dialogRef, isOpen, setIsOpen]);
useEffect(() => {
if (isOpen && dialogRef.current) {
dialogRef.current.showModal();
}
}, [isOpen]);
const handleTrigger = () => {
setIsOpen(true);
};
const onAnimationComplete = (definition: string) => {
if (definition === "exit" && !isOpen) {
dialogRef.current?.close();
}
};
const baseId = useId();
const ids = {
dialog: `motion-ui-dialog-${baseId}`,
title: `motion-ui-dialog-title-${baseId}`,
description: `motion-ui-dialog-description-${baseId}`,
};
return (
<DialogContext.Provider
value={{
isOpen,
setIsOpen,
dialogRef,
variants,
transition,
ids,
onAnimationComplete,
handleTrigger,
}}
>
{children}
</DialogContext.Provider>
);
}
export type DialogTriggerProps = {
children: React.ReactNode;
className?: string;
};
function DialogTrigger({ children, className }: DialogTriggerProps) {
const context = useContext(DialogContext);
if (!context) throw new Error("DialogTrigger must be used within Dialog");
return (
<button
onClick={context.handleTrigger}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium",
"transition-colors focus-visible:ring-2 focus-visible:outline-hidden",
"focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className,
)}
>
{children}
</button>
);
}
export type DialogPortalProps = {
children: React.ReactNode;
container?: HTMLElement | null;
};
function DialogPortal({
children,
container = typeof window !== "undefined" ? document.body : null,
}: DialogPortalProps) {
const [mounted, setMounted] = React.useState(false);
const [portalContainer, setPortalContainer] =
React.useState<HTMLElement | null>(null);
useEffect(() => {
setMounted(true);
setPortalContainer(container || document.body);
return () => setMounted(false);
}, [container]);
if (!mounted || !portalContainer) {
return null;
}
return createPortal(children, portalContainer);
}
export type DialogContentProps = {
children: React.ReactNode;
className?: string;
container?: HTMLElement;
};
function DialogContent({ children, className, container }: DialogContentProps) {
const context = useContext(DialogContext);
if (!context) throw new Error("DialogContent must be used within Dialog");
const {
isOpen,
setIsOpen,
dialogRef,
variants,
transition,
ids,
onAnimationComplete,
} = context;
const content = (
<AnimatePresence mode="wait">
{isOpen && (
<motion.dialog
key={ids.dialog}
ref={dialogRef as React.RefObject<HTMLDialogElement>}
id={ids.dialog}
aria-labelledby={ids.title}
aria-describedby={ids.description}
aria-modal="true"
role="dialog"
onClick={(e) => {
if (e.target === dialogRef.current) {
setIsOpen(false);
}
}}
initial="initial"
animate="animate"
exit="exit"
variants={variants}
transition={transition}
onAnimationComplete={onAnimationComplete}
className={cn(
"fixed transform rounded-lg border border-zinc-200 p-0 shadow-lg dark:border dark:border-zinc-700",
"backdrop:bg-black/50 backdrop:backdrop-blur-xs",
"open:flex open:flex-col",
className,
)}
>
<div className="w-full">{children}</div>
</motion.dialog>
)}
</AnimatePresence>
);
return <DialogPortal container={container}>{content}</DialogPortal>;
}
export type DialogHeaderProps = {
children: React.ReactNode;
className?: string;
};
function DialogHeader({ children, className }: DialogHeaderProps) {
return (
<div className={cn("flex flex-col space-y-1.5", className)}>
{children}
</div>
);
}
export type DialogTitleProps = {
children: React.ReactNode;
className?: string;
};
function DialogTitle({ children, className }: DialogTitleProps) {
const context = useContext(DialogContext);
if (!context) throw new Error("DialogTitle must be used within Dialog");
return (
<h2
id={context.ids.title}
className={cn("text-base font-medium", className)}
>
{children}
</h2>
);
}
export type DialogDescriptionProps = {
children: React.ReactNode;
className?: string;
};
function DialogDescription({ children, className }: DialogDescriptionProps) {
const context = useContext(DialogContext);
if (!context)
throw new Error("DialogDescription must be used within Dialog");
return (
<p
id={context.ids.description}
className={cn("text-base text-zinc-500", className)}
>
{children}
</p>
);
}
export type DialogCloseProps = {
className?: string;
children?: React.ReactNode;
disabled?: boolean;
};
function DialogClose({ className, children, disabled }: DialogCloseProps) {
const context = useContext(DialogContext);
if (!context) throw new Error("DialogClose must be used within Dialog");
return (
<button
onClick={() => context.setIsOpen(false)}
type="button"
aria-label="Close dialog"
className={cn(
"absolute top-4 right-4 rounded-xs opacity-70 transition-opacity",
"hover:opacity-100 focus:ring-2 focus:outline-hidden",
"focus:ring-zinc-500 focus:ring-offset-2 disabled:pointer-events-none",
className,
)}
disabled={disabled}
>
{children || <X className="h-4 w-4" />}
<span className="sr-only">Close</span>
</button>
);
}
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogClose,
};