Back

Morph modal

A reusable modal component that smoothly "morphs" from the dimensions and position of its trigger element

Current Theme:

API usage

components/morph-modal.demo.tsx
"use client";

import { PlayIcon } from "lucide-react";

import {
  MorphModal,
  MorphModalTrigger,
  MorphModalContent,
  MorphModalOverlay,
} from "@/components/morph-modal";

export function MorphModalDemo() {
  return (
    <MorphModal transition={{ ease: "easeInOut" }}>
      <MorphModalTrigger className="bg-secondary text-secondary-foreground border-border/50 rounded-full border px-3.5 py-2">
        <div className="flex items-center gap-1.5">
          Watch demo
          <PlayIcon className="fill-foreground stroke-foreground bg-background box-content size-3.5 rounded-full p-1" />
        </div>
      </MorphModalTrigger>
      <MorphModalOverlay className="z-50">
        <MorphModalContent className="bg-muted border-border/50 m-2 rounded-xl border p-1">
          <iframe
            className="aspect-[560/315] max-w-full rounded-lg"
            width="768px"
            height="auto"
            src="https://www.youtube.com/embed/aWBiZc5XKJM?si=muuRWjXzomYeoQ2K"
            title="YouTube video player"
            allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
            referrerPolicy="strict-origin-when-cross-origin"
            allowFullScreen
          />
        </MorphModalContent>
      </MorphModalOverlay>
    </MorphModal>
  );
}

Installation

CLI (recommended)

1
Run the command below to add the component to your project.
It will also generate the required base stylesheet if one doesn't already exist and guide you through setting up the import alias @/components/... if it isn't already configured.
pnpm dlx shadcn@latest add https://100xui.com/components/morph-modal.json

Manual

1
Install the following dependencies.
pnpm add clsx motion tailwind-merge
2
Copy and paste the following code into your project.
100xui
components/morph-link.tsx
"use client";

import React from "react";
import {
  AnimatePresence,
  motion,
  type HTMLMotionProps,
  type Transition,
} from "motion/react";
import { cn } from "@/lib/utils";

function assertOnlyChild(children: React.ReactNode) {
  if (Array.isArray(children))
    throw new Error(
      "A single child is required, but received multiple siblings.",
    );
  if (
    children &&
    React.isValidElement(children) &&
    children.type === React.Fragment &&
    Array.isArray((children.props as React.FragmentProps).children)
  )
    throw new Error(
      "A single child is required; fragments that render multiple siblings are not allowed.",
    );
}

type MorphModalContextType = {
  openModal: boolean;
  setOpenModal: React.Dispatch<React.SetStateAction<boolean>>;
  modalId: string;
  transition: Transition;
};

const MorphModalContext = React.createContext<
  MorphModalContextType | undefined
>(undefined);

export const useMorphModal = () => {
  const ctx = React.useContext(MorphModalContext);
  if (!ctx)
    throw new Error("useMorphModal must be used within a <MorphModal />");
  return ctx;
};

interface MorphModalProps {
  children: React.ReactNode;
  transition?: Omit<Transition, "delay">;
}

const DEFAULT_DURATION = 0.3;
const DEFAULT_EASE = "easeInOut";

export function MorphModal({ children, transition }: MorphModalProps) {
  const modalId = React.useId();
  const [openModal, setOpenModal] = React.useState(false);

  return (
    <MorphModalContext.Provider
      value={{
        openModal,
        setOpenModal,
        modalId,
        transition: {
          duration: DEFAULT_DURATION,
          ease: DEFAULT_EASE,
          ...transition,
        },
      }}
    >
      {children}
    </MorphModalContext.Provider>
  );
}

interface MorphModalTriggerProps extends HTMLMotionProps<"button"> {
  children: React.ReactNode;
}

export function MorphModalTrigger({
  children,
  className,
  onClick,
  ...rest
}: MorphModalTriggerProps) {
  assertOnlyChild(children);

  const { openModal, setOpenModal, transition, modalId } = useMorphModal();
  const { duration, ...restTransition } = transition;

  return (
    <div className="relative">
      <AnimatePresence initial={false}>
        {!openModal && (
          <motion.button
            onClick={(e) => {
              setOpenModal(true);
              onClick?.(e);
            }}
            className={cn(
              "bg-primary text-primary-foreground hover:bg-accent/80 hover:text-accent-foreground/80 size-fit cursor-pointer overflow-hidden transition-colors duration-150 ease-out",
              className,
              "!absolute !inset-0",
            )}
            {...rest}
            transition={{ layout: { duration, ...restTransition } }}
            layoutId={modalId + "-morph-modal"}
          >
            <motion.div
              initial={{ opacity: 0 }}
              animate={{
                opacity: 1,
                transition: { delay: duration, ...restTransition },
              }}
              exit={{ opacity: 0 }}
              layoutId={modalId + "morph-modal-trigger-child"}
              transition={{ duration, ...restTransition }}
            >
              {children}
            </motion.div>
          </motion.button>
        )}
      </AnimatePresence>
      <div className={cn("invisible", className)} aria-hidden>
        {children}
      </div>
    </div>
  );
}

interface MorphModalContentProps extends HTMLMotionProps<"div"> {
  children: React.ReactNode;
}

export function MorphModalContent({
  children,
  className,
  onClick,
  ...rest
}: MorphModalContentProps) {
  assertOnlyChild(children);

  const { transition, modalId } = useMorphModal();
  const { duration, ...restTransition } = transition;

  return (
    <motion.div
      layoutId={modalId + "-morph-modal"}
      transition={{ layout: { duration, ...restTransition } }}
      className={cn("size-fit overflow-hidden", className)}
      {...rest}
    >
      <motion.div
        layoutId={modalId + "morph-modal-content-child"}
        initial={{ opacity: 0 }}
        animate={{
          opacity: 1,
          transition: { delay: duration, ...restTransition },
        }}
        exit={{ opacity: 0 }}
        onClick={(e) => {
          e.stopPropagation();
          onClick?.(e);
        }}
        {...rest}
      >
        {children}
      </motion.div>
    </motion.div>
  );
}

interface MorphModalOverlayProps extends HTMLMotionProps<"div"> {
  children: React.ReactNode;
}

export function MorphModalOverlay({
  children,
  className,
  onClick,
  ...rest
}: MorphModalOverlayProps) {
  const { openModal, setOpenModal, transition } = useMorphModal();

  return (
    <AnimatePresence>
      {openModal && (
        <motion.div
          initial={{ backdropFilter: "blur(0px)" }}
          animate={{ backdropFilter: "blur(2px)" }}
          exit={{ backdropFilter: "blur(0px)" }}
          transition={transition}
          className={cn(
            "fixed inset-0 isolate z-50 grid place-items-center",
            className,
          )}
          onClick={(e) => {
            setOpenModal(false);
            onClick?.(e);
          }}
          {...rest}
        >
          {children}
        </motion.div>
      )}
    </AnimatePresence>
  );
}
3
Finally, Update the import paths to match your project setup.

API reference

<MorphModal/>

PropsTypeDescriptionDefault value
children
React.ReactNode
React nodes that will be rendered inside the MorphModal as children. This typically includes the MorphModalTrigger and the MorphModalOverlay.
(required)
transition?
Omit<Transition, "delay">
Object that defines the animation properties for the morphing transition except delay
undefined

<MorphModalTrigger/>

PropsTypeDescriptionDefault value
children
React.ReactNode
The single child element to be rendered inside the trigger, such as text, an icon, or a single nested component.
(required)
...rest
HTMLMotionProps<"button">
Any standard React button props, like onClick handlers or disabled states, which will be applied directly to the button
undefined

<MorphModalOverlay/>

PropsTypeDescriptionDefault value
children
React.ReactNode
The content to be rendered inside the overlay. This should typically be the MorphModalContent component.
(required)
...rest
HTMLMotionProps<"div">
Any standard React div props, like id, style or className, which will be applied directly to the component's root element.
undefined

<MorphModalContent/>

PropsTypeDescriptionDefault value
children
React.ReactNode
The single child element to be displayed inside the modal (e.g., text, videos, forms, or custom components).
(required)
...rest
HTMLMotionProps<"div">
Any standard React div props, like id, style or className, which will be applied directly to the component's root element.
undefined