Command Palette

Search for a command to run...

Morphing Dialog

An interactive collection of stacked headings that reveal individual content sections with a click.

Installation

Install the following dependencies:

pnpm add motion

Create a file at components/motion/morphing-dialog.tsx and paste the given code.

"use client";
 
import React, {
    useCallback,
    useContext,
    useEffect,
    useId,
    useMemo,
    useRef,
    useState,
} from "react";
import {
    motion,
    AnimatePresence,
    MotionConfig,
    Transition,
    Variant,
} from "motion/react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { XIcon } from "lucide-react";
import useClickOutside from "@/hooks/use-click-outside";
 
export type MorphingDialogContextType = {
    isOpen: boolean;
    setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
    uniqueId: string;
    triggerRef: React.RefObject<HTMLDivElement>;
};
 
const MorphingDialogContext =
    React.createContext<MorphingDialogContextType | null>(null);
 
function useMorphingDialog() {
    const context = useContext(MorphingDialogContext);
    if (!context) {
        throw new Error(
            "useMorphingDialog must be used within a MorphingDialogProvider",
        );
    }
    return context;
}
 
export type MorphingDialogProviderProps = {
    children: React.ReactNode;
    transition?: Transition;
};
 
function MorphingDialogProvider({
    children,
    transition,
}: MorphingDialogProviderProps) {
    const [isOpen, setIsOpen] = useState(false);
    const uniqueId = useId();
    const triggerRef = useRef<HTMLDivElement>(null!);
 
    const contextValue = useMemo(
        () => ({
            isOpen,
            setIsOpen,
            uniqueId,
            triggerRef,
        }),
        [isOpen, uniqueId],
    );
 
    return (
        <MorphingDialogContext.Provider value={contextValue}>
            <MotionConfig transition={transition}>{children}</MotionConfig>
        </MorphingDialogContext.Provider>
    );
}
 
export type MorphingDialogProps = {
    children: React.ReactNode;
    transition?: Transition;
};
 
function MorphingDialog({ children, transition }: MorphingDialogProps) {
    return (
        <MorphingDialogProvider>
            <MotionConfig transition={transition}>{children}</MotionConfig>
        </MorphingDialogProvider>
    );
}
 
export type MorphingDialogTriggerProps = {
    children: React.ReactNode;
    className?: string;
    style?: React.CSSProperties;
    triggerRef?: React.RefObject<HTMLDivElement>;
};
 
function MorphingDialogTrigger({
    children,
    className,
    style,
    triggerRef,
}: MorphingDialogTriggerProps) {
    const { setIsOpen, isOpen, uniqueId } = useMorphingDialog();
 
    const handleClick = useCallback(() => {
        setIsOpen(!isOpen);
    }, [isOpen, setIsOpen]);
 
    const handleKeyDown = useCallback(
        (event: React.KeyboardEvent) => {
            if (event.key === "Enter" || event.key === " ") {
                event.preventDefault();
                setIsOpen(!isOpen);
            }
        },
        [isOpen, setIsOpen],
    );
 
    return (
        <motion.div
            ref={triggerRef}
            layoutId={`dialog-${uniqueId}`}
            className={cn("relative cursor-pointer", className)}
            onClick={handleClick}
            onKeyDown={handleKeyDown}
            style={style}
            role="button"
            aria-haspopup="dialog"
            aria-expanded={isOpen}
            aria-controls={`motion-ui-morphing-dialog-content-${uniqueId}`}
            aria-label={`Open dialog ${uniqueId}`}
        >
            {children}
        </motion.div>
    );
}
 
export type MorphingDialogContentProps = {
    children: React.ReactNode;
    className?: string;
    style?: React.CSSProperties;
};
 
function MorphingDialogContent({
    children,
    className,
    style,
}: MorphingDialogContentProps) {
    const { setIsOpen, isOpen, uniqueId, triggerRef } = useMorphingDialog();
    const containerRef = useRef<HTMLDivElement>(null!);
    const [firstFocusableElement, setFirstFocusableElement] =
        useState<HTMLElement | null>(null);
    const [lastFocusableElement, setLastFocusableElement] =
        useState<HTMLElement | null>(null);
 
    useEffect(() => {
        const handleKeyDown = (event: KeyboardEvent) => {
            if (event.key === "Escape") {
                setIsOpen(false);
            }
            if (event.key === "Tab") {
                if (!firstFocusableElement || !lastFocusableElement) return;
 
                if (event.shiftKey) {
                    if (document.activeElement === firstFocusableElement) {
                        event.preventDefault();
                        lastFocusableElement.focus();
                    }
                } else {
                    if (document.activeElement === lastFocusableElement) {
                        event.preventDefault();
                        firstFocusableElement.focus();
                    }
                }
            }
        };
 
        document.addEventListener("keydown", handleKeyDown);
 
        return () => {
            document.removeEventListener("keydown", handleKeyDown);
        };
    }, [setIsOpen, firstFocusableElement, lastFocusableElement]);
 
    useEffect(() => {
        if (isOpen) {
            document.body.classList.add("overflow-hidden");
            const focusableElements = containerRef.current?.querySelectorAll(
                'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
            );
            if (focusableElements && focusableElements.length > 0) {
                setFirstFocusableElement(focusableElements[0] as HTMLElement);
                setLastFocusableElement(
                    focusableElements[
                        focusableElements.length - 1
                    ] as HTMLElement,
                );
                (focusableElements[0] as HTMLElement).focus();
            }
        } else {
            document.body.classList.remove("overflow-hidden");
            triggerRef.current?.focus();
        }
    }, [isOpen, triggerRef]);
 
    useClickOutside(containerRef, () => {
        if (isOpen) {
            setIsOpen(false);
        }
    });
 
    return (
        <motion.div
            ref={containerRef}
            layoutId={`dialog-${uniqueId}`}
            className={cn("overflow-hidden", className)}
            style={style}
            role="dialog"
            aria-modal="true"
            aria-labelledby={`motion-ui-morphing-dialog-title-${uniqueId}`}
            aria-describedby={`motion-ui-morphing-dialog-description-${uniqueId}`}
        >
            {children}
        </motion.div>
    );
}
 
export type MorphingDialogContainerProps = {
    children: React.ReactNode;
    className?: string;
    style?: React.CSSProperties;
};
 
function MorphingDialogContainer({ children }: MorphingDialogContainerProps) {
    const { isOpen, uniqueId } = useMorphingDialog();
    const [mounted, setMounted] = useState(false);
 
    useEffect(() => {
        setMounted(true);
        return () => setMounted(false);
    }, []);
 
    if (!mounted) return null;
 
    return createPortal(
        <AnimatePresence initial={false} mode="sync">
            {isOpen && (
                <>
                    <motion.div
                        key={`backdrop-${uniqueId}`}
                        className="backdrop-blur-xs z-[999] fixed inset-0 h-full w-full bg-black/80"
                        initial={{ opacity: 0 }}
                        animate={{ opacity: 1 }}
                        exit={{ opacity: 0 }}
                    />
                    <div className="fixed inset-0 z-[999] flex items-center justify-center">
                        {children}
                    </div>
                </>
            )}
        </AnimatePresence>,
        document.body,
    );
}
 
export type MorphingDialogTitleProps = {
    children: React.ReactNode;
    className?: string;
    style?: React.CSSProperties;
};
 
function MorphingDialogTitle({
    children,
    className,
    style,
}: MorphingDialogTitleProps) {
    const { uniqueId } = useMorphingDialog();
 
    return (
        <motion.div
            layoutId={`dialog-title-container-${uniqueId}`}
            className={className}
            style={style}
            layout
        >
            {children}
        </motion.div>
    );
}
 
export type MorphingDialogSubtitleProps = {
    children: React.ReactNode;
    className?: string;
    style?: React.CSSProperties;
};
 
function MorphingDialogSubtitle({
    children,
    className,
    style,
}: MorphingDialogSubtitleProps) {
    const { uniqueId } = useMorphingDialog();
 
    return (
        <motion.div
            layoutId={`dialog-subtitle-container-${uniqueId}`}
            className={className}
            style={style}
        >
            {children}
        </motion.div>
    );
}
 
export type MorphingDialogDescriptionProps = {
    children: React.ReactNode;
    className?: string;
    disableLayoutAnimation?: boolean;
    variants?: {
        initial: Variant;
        animate: Variant;
        exit: Variant;
    };
};
 
function MorphingDialogDescription({
    children,
    className,
    variants,
    disableLayoutAnimation,
}: MorphingDialogDescriptionProps) {
    const { uniqueId } = useMorphingDialog();
 
    return (
        <motion.div
            key={`dialog-description-${uniqueId}`}
            layoutId={
                disableLayoutAnimation
                    ? undefined
                    : `dialog-description-content-${uniqueId}`
            }
            variants={variants}
            className={className}
            initial="initial"
            animate="animate"
            exit="exit"
            id={`dialog-description-${uniqueId}`}
        >
            {children}
        </motion.div>
    );
}
 
export type MorphingDialogImageProps = {
    src: string;
    alt: string;
    className?: string;
    style?: React.CSSProperties;
};
 
function MorphingDialogImage({
    src,
    alt,
    className,
    style,
}: MorphingDialogImageProps) {
    const { uniqueId } = useMorphingDialog();
 
    return (
        <motion.img
            src={src}
            alt={alt}
            className={cn(className)}
            layoutId={`dialog-img-${uniqueId}`}
            style={style}
        />
    );
}
 
export type MorphingDialogCloseProps = {
    children?: React.ReactNode;
    className?: string;
    variants?: {
        initial: Variant;
        animate: Variant;
        exit: Variant;
    };
};
 
function MorphingDialogClose({
    children,
    className,
    variants,
}: MorphingDialogCloseProps) {
    const { setIsOpen, uniqueId } = useMorphingDialog();
 
    const handleClose = useCallback(() => {
        setIsOpen(false);
    }, [setIsOpen]);
 
    return (
        <motion.button
            onClick={handleClose}
            type="button"
            aria-label="Close dialog"
            key={`dialog-close-${uniqueId}`}
            className={cn("absolute right-6 top-6", className)}
            initial="initial"
            animate="animate"
            exit="exit"
            variants={variants}
        >
            {children || <XIcon size={24} />}
        </motion.button>
    );
}
 
export {
    MorphingDialog,
    MorphingDialogTrigger,
    MorphingDialogContainer,
    MorphingDialogContent,
    MorphingDialogClose,
    MorphingDialogTitle,
    MorphingDialogSubtitle,
    MorphingDialogDescription,
    MorphingDialogImage,
};

Update the import paths to match your project setup.