Dialog

A dialog component.

Basic dialog

Loading...

Dialog with custom variant and transition

Loading...

Dialog with custom backdrop

Loading...

Controlled dialog

Loading...

Usage Guide

Install the clsx and tailwind-merge packages:

npm install clsx tw-merge framer-motion

Next, add cn utility:

import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from "clsx";

export function cn(...inputs: ClassValue[]) {
    return twMerge(clsx(inputs));
}

Copy and paste the following component source.

"use client";

import React from "react";
import { cn } from "@/lib/utils";
import { createPortal } from "react-dom";
import { Transition, Variants } from "framer-motion";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";

// dialog context
const DialogContext = React.createContext<{
    isOpen: boolean;
    setIsOpen: (isOpen: boolean) => void;
    titleId: string;
    descriptionId: string;
    variants: Variants;
    transition: Transition;
} | null>(null);

// useDialog hook
const useDialog = () => {
    const context = React.useContext(DialogContext);
    if (!context) {
        throw new Error("Dialog components must be used within a Dialog");
    }
    return context;
};

/* -------------------------------------------------------------------------------------------------
 * Dialog (provider) component
 * -------------------------------------------------------------------------------------------------
 */
interface DialogProps {
    children: React.ReactNode;
    variants?: Variants;
    transition?: Transition;
    defaultOpen?: boolean;
    open?: boolean;
    onOpenChange?: (isOpen: boolean) => void;
}

const defaultVariants: Variants = {
    initial: {
        opacity: 0,
        scale: 0.95
    },
    animate: {
        opacity: 1,
        scale: 1
    }
};

const defaultTransition: Transition = {
    ease: "easeOut",
    duration: 0.2
};

const Dialog: React.FC<DialogProps> = ({
    children,
    variants = defaultVariants,
    transition = defaultTransition,
    defaultOpen = false,
    onOpenChange,
    open
}) => {
    const isControlled = open !== undefined;
    const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
    const isOpen = isControlled ? open : uncontrolledOpen;

    const setIsOpen = React.useCallback(
        (newOpen: boolean) => {
            if (!isControlled) {
                setUncontrolledOpen(newOpen);
            }
            onOpenChange?.(newOpen);
        },
        [isControlled, onOpenChange]
    );

    const titleId = React.useRef(
        `dialog-title-${Math.random().toString(36).substring(2, 9)}`
    ).current;
    const descriptionId = React.useRef(
        `dialog-description-${Math.random().toString(36).substring(2, 9)}`
    ).current;

    React.useEffect(() => {
        onOpenChange?.(isOpen);
    }, [isOpen, onOpenChange]);

    return (
        <DialogContext.Provider
            value={{ isOpen, setIsOpen, titleId, descriptionId, variants, transition }}
        >
            {children}
        </DialogContext.Provider>
    );
};

/* -------------------------------------------------------------------------------------------------
 * DialogTrigger component
 * -------------------------------------------------------------------------------------------------
 */
interface DialogTriggerProps {
    children: React.ReactNode;
    asChild?: boolean;
    className?: string;
}

const DialogTrigger: React.FC<DialogTriggerProps> = ({ children, asChild = false, className }) => {
    const { setIsOpen } = useDialog();

    const handleClick = (e: React.MouseEvent) => {
        e.preventDefault();
        setIsOpen(true);
    };

    if (asChild && React.isValidElement(children)) {
        return React.cloneElement(children as React.ReactElement<any>, {
            onClick: (e: React.MouseEvent) => {
                (children as React.ReactElement<any>).props.onClick?.(e);
                handleClick(e);
            }
        });
    }

    return (
        <button
            onClick={handleClick}
            className={cn(
                "px-4 py-2 text-sm font-medium rounded-md border transition-colors focus:outline-none",
                "bg-white text-black border-gray-200 hover:bg-gray-200 focus:ring-2 focus:ring-gray-300",
                "dark:bg-zinc-800 dark:text-white dark:border-zinc-700 dark:hover:bg-gray-800 dark:focus:ring-gray-600",
                className
            )}
        >
            {children}
        </button>
    );
};

/* -------------------------------------------------------------------------------------------------
 * DialogContent component
 * -------------------------------------------------------------------------------------------------
 */
interface DialogContentProps {
    children: React.ReactNode;
    onClose?: () => void;
    className?: string;
    backdropClassName?: string;
}

const DialogContent: React.FC<DialogContentProps> = ({
    children,
    onClose,
    className,
    backdropClassName
}) => {
    const { isOpen, setIsOpen, titleId, descriptionId, variants, transition } = useDialog();
    const contentRef = React.useRef<HTMLDivElement>(null);
    const dialogRef = React.useRef<HTMLDivElement>(null);

    React.useEffect(() => {
        const handleEscape = (event: KeyboardEvent) => {
            if (event.key === "Escape") {
                setIsOpen(false);
                onClose?.();
            }
        };

        if (isOpen) {
            document.addEventListener("keydown", handleEscape);
            return () => document.removeEventListener("keydown", handleEscape);
        }
    }, [isOpen, setIsOpen, onClose]);

    React.useEffect(() => {
        if (isOpen && contentRef.current) {
            const focusableElements = Array.from(
                contentRef.current.querySelectorAll(
                    'button, [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
                )
            ) as HTMLElement[];

            if (focusableElements.length > 0) {
                const firstElement = focusableElements[0];
                const lastElement = focusableElements[focusableElements.length - 1];

                const handleTabKey = (event: KeyboardEvent) => {
                    if (event.key === "Tab") {
                        if (event.shiftKey && document.activeElement === firstElement) {
                            event.preventDefault();
                            lastElement.focus();
                        } else if (!event.shiftKey && document.activeElement === lastElement) {
                            event.preventDefault();
                            firstElement.focus();
                        }
                    }
                };

                firstElement.focus();

                document.addEventListener("keydown", handleTabKey);
                return () => document.removeEventListener("keydown", handleTabKey);
            }
        }
    }, [isOpen]);

    const portalContent = (
        <AnimatePresence>
            {isOpen && (
                <motion.div
                    ref={dialogRef}
                    initial={{ opacity: 0 }}
                    animate={{ opacity: 1 }}
                    exit={{ opacity: 0 }}
                    transition={{ duration: 0.2 }}
                    variants={variants}
                    className={cn(
                        "fixed inset-0 z-50 flex items-center justify-center overflow-y-auto",
                        "bg-black/50 backdrop-blur-sm",
                        backdropClassName
                    )}
                    onClick={(e) => {
                        if (e.target === dialogRef.current) {
                            setIsOpen(false);
                        }
                    }}
                >
                    <motion.div
                        ref={contentRef}
                        initial="initial"
                        animate="animate"
                        exit="exit"
                        transition={transition}
                        variants={variants}
                        className={cn(
                            "w-full max-w-lg mx-auto p-6 relative bg-white rounded-lg shadow-md",
                            "dark:bg-zinc-800 dark:shadow-lg",
                            className
                        )}
                        role="dialog"
                        aria-modal="true"
                        aria-labelledby={titleId}
                        aria-describedby={descriptionId}
                    >
                        {children}
                    </motion.div>
                </motion.div>
            )}
        </AnimatePresence>
    );

    return typeof window !== "undefined" ? createPortal(portalContent, document.body) : null;
};

/* -------------------------------------------------------------------------------------------------
 * DialogHeader component
 * -------------------------------------------------------------------------------------------------
 */
const DialogHeader: React.FC<{ children: React.ReactNode; className?: string }> = ({
    children,
    className
}) => <div className={cn("mb-4", className)}>{children}</div>;

/* -------------------------------------------------------------------------------------------------
 * DialogTitle component
 * -------------------------------------------------------------------------------------------------
 */
const DialogTitle: React.FC<{ children: React.ReactNode; className?: string }> = ({
    children,
    className
}) => {
    const { titleId } = useDialog();
    return (
        <h2 id={titleId} className={cn("text-xl font-semibold dark:text-white", className)}>
            {children}
        </h2>
    );
};

/* -------------------------------------------------------------------------------------------------
 * DialogDscription component
 * -------------------------------------------------------------------------------------------------
 */
const DialogDescription: React.FC<{ children: React.ReactNode; className?: string }> = ({
    children,
    className
}) => {
    const { descriptionId } = useDialog();
    return (
        <p id={descriptionId} className={cn("text-sm text-gray-400 dark:text-gray-500", className)}>
            {children}
        </p>
    );
};

/* -------------------------------------------------------------------------------------------------
 * DialogClose component
 * -------------------------------------------------------------------------------------------------
 */
interface DialogCloseProps {
    children?: React.ReactNode;
    asChild?: boolean;
    className?: string;
}

const DialogClose: React.FC<DialogCloseProps> = ({ children, asChild = false, className }) => {
    const { setIsOpen } = useDialog();

    const handleClose = (e: React.MouseEvent) => {
        e.preventDefault();
        setIsOpen(false);
    };

    if (asChild && React.isValidElement(children)) {
        return React.cloneElement(children as React.ReactElement<any>, {
            onClick: (e: React.MouseEvent) => {
                (children as React.ReactElement<any>).props.onClick?.(e);
                handleClose(e);
            }
        });
    }

    return (
        <button
            onClick={handleClose}
            className={cn(
                "text-sm border border-gray-200 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-100",
                "dark:text-gray-300 dark:border-zinc-700 dark:hover:text-white dark:hover:bg-gray-800 dark:focus:ring-zinc-800",
                className
            )}
        >
            {children || <X className="h-4 w-4" />}
        </button>
    );
};

export {
    Dialog,
    DialogTrigger,
    DialogHeader,
    DialogContent,
    DialogTitle,
    DialogDescription,
    DialogClose,
    useDialog
};

Props

Dialog

PropTypeDescriptionDefault
childrenReactNodeThe content to be rendered inside the component_
variantsObjectFramer motion variants._
transitionObjectFramer motion transition._
defaultOpenbooleanIf true, the dialog is open by default.false
openbooleanControls whether the dialog is open or closed.undefined
onOpenChange(isOpen: boolean) => voidCallback function triggered whenever the open state of the dialog changes.undefined

DialogTrigger

PropTypeDescriptionDefault
childrenReactNodeThe content to be rendered inside the component_
classNamestringString of class_
asChildbooleanIf false, returns button other wise returns childrenfalse

DialogHeader

PropTypeDescriptionDefault
childrenReactNodeThe content to be rendered inside the component_
classNamestringString of class_

DialogContent

PropTypeDescriptionDefault
childrenReactNodeThe content to be rendered inside the component_
classNamestringString of class for main dialog content_
backdropClassNamestringString of class for dialog background_
onCloseonClose?: () => voidIf false, returns button other wise returns childrenundefined

DialogTitle

PropTypeDescriptionDefault
childrenReactNodeThe content to be rendered inside the component_
classNamestringString of class_

DialogDesciption

PropTypeDescriptionDefault
childrenReactNodeThe content to be rendered inside the component_
classNamestringString of class_

DialogClose

PropTypeDescriptionDefault
childrenReactNodeThe content to be rendered inside the component_
classNamestringString of class_
asChildbooleanIf false, returns button other wise returns childrenfalse