morph-image

PreviousNext

A morph image component for shadcn/ui. Easily create a morph image with a thumbnail and a modal

Docs
shadix-uicomponent

Preview

Loading preview…
registry/new-york/components/morph-image.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type React from "react";

import { AnimatePresence, motion, type Transition } from "motion/react";

import { useClickOutside } from "@/registry/new-york/hooks/useClickOutside";
import { useEventListener } from "@/registry/new-york/hooks/useEventListener";
import { cn } from "@/shadcn/lib/utils";

const transition: Transition = {
    duration: 0.3,
    ease: [0.4, 0, 0.2, 1],
    type: "spring",
    stiffness: 120,
    damping: 15,
};

const MorphImage: React.FC<React.ComponentProps<typeof motion.img>> = ({
    src,
    className,
    alt,
    onClick,
    ...props
}) => {
    const [isOpen, setIsOpen] = useState(false);
    const [mounted, setMounted] = useState(false);

    const imageRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        setMounted(true);

        return () => setMounted(false);
    }, []);

    useClickOutside({
        ref: imageRef,
        callback: () => setIsOpen(false),
    });

    useEventListener("scroll", () => isOpen && setIsOpen(false));

    const handleClick = (e: React.MouseEvent<HTMLImageElement, MouseEvent>) => {
        e.stopPropagation();
        setIsOpen((prev) => !prev);
        onClick?.(e);
    };

    if (!mounted) return null;

    const thumbnail = (
        <motion.img
            src={src}
            alt={alt}
            layoutId="morph-image"
            className={cn(
                "w-full h-full object-cover object-center not-prose cursor-zoom-in",
                className,
            )}
            onClick={() => setIsOpen(true)}
            transition={transition}
            {...props}
        />
    );

    const modal = createPortal(
        <AnimatePresence mode="wait">
            {isOpen && (
                <>
                    <motion.div
                        key="backdrop"
                        className="fixed inset-0 z-40 bg-black/80 cursor-pointer"
                        initial={{ opacity: 0, pointerEvents: "none" }}
                        animate={{ opacity: 1, pointerEvents: "auto" }}
                        exit={{ opacity: 0, pointerEvents: "none" }}
                        transition={transition}
                    />
                    <motion.div
                        key="container"
                        className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none "
                        initial={{ opacity: 0 }}
                        animate={{ opacity: 1 }}
                        exit={{ opacity: 0 }}
                        transition={transition}
                    >
                        <motion.img
                            ref={imageRef as React.RefObject<HTMLImageElement>}
                            src={src}
                            alt={alt}
                            layoutId={props.layoutId || "morph-image"}
                            className={cn(
                                "object-cover object-center max-w-[90vw] max-h-[90vh] pointer-events-auto cursor-zoom-out rounded-lg overflow-hidden",
                            )}
                            onClick={(e) => handleClick(e)}
                            transition={transition}
                        />
                    </motion.div>
                </>
            )}
        </AnimatePresence>,
        document.body,
    );

    return (
        <div className="w-full h-full flex items-center justify-center">
            <picture className="w-full h-full">{thumbnail}</picture>
            {modal}
        </div>
    );
};

export default MorphImage;

Installation

npx shadcn@latest add @shadix-ui/morph-image

Usage

import { MorphImage } from "@/components/morph-image"
<MorphImage />