Media Modals

A media modal component that opens images or videos with a smooth animation

A desk lamp designed by Edouard Wilfrid Buquet in 1925. It features a double-arm design and is made from nickel-plated brass, aluminium and varnished wood.

Installation

npm install framer-motion
media-modal.tsx
import React, { useEffect, useId, useState } from 'react'
import { AnimatePresence, motion, MotionConfig } from 'framer-motion'
import { cn } from '@/lib/utils'
import { useMediaQuery } from '@/hooks/use-media-query'
import { XIcon } from 'lucide-react'
import { createPortal } from 'react-dom'
 
interface IMediaModal {
  imgSrc?: string
  videoSrc?: string
  className?: string
}
const transition = {
  type: 'spring',
  duration: 0.4,
}
export function MediaModal({ imgSrc, videoSrc, className }: IMediaModal) {
  const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
  const isDesktop = useMediaQuery('(min-width:768px)')
  const uniqueId = useId()
 
  useEffect(() => {
    if (isMediaModalOpen) {
      document.body.classList.add('overflow-hidden')
    } else {
      document.body.classList.remove('overflow-hidden')
    }
 
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        setIsMediaModalOpen(false)
      }
    }
 
    document.addEventListener('keydown', handleKeyDown)
    return () => {
      document.removeEventListener('keydown', handleKeyDown)
    }
  }, [isMediaModalOpen])
  return (
    <>
      <MotionConfig transition={transition}>
        <>
          <motion.div
            // @ts-ignore
            className="w-full h-full flex relative  flex-col overflow-hidden border    dark:bg-black bg-gray-300 hover:bg-gray-200 dark:hover:bg-gray-950"
            layoutId={`dialog-${uniqueId}`}
            style={{
              borderRadius: '12px',
            }}
            onClick={() => {
              setIsMediaModalOpen(true)
            }}
          >
            {imgSrc && (
              <motion.div
                layoutId={`dialog-img-${uniqueId}`}
                className="w-full h-full"
              >
                <img
                  src={imgSrc}
                  alt="A desk lamp designed by Edouard Wilfrid Buquet in 1925. It features a double-arm design and is made from nickel-plated brass, aluminium and varnished wood."
                  className=" w-full object-cover h-full"
                />
              </motion.div>
            )}
            {videoSrc && (
              <motion.div
                layoutId={`dialog-video-${uniqueId}`}
                className="w-full h-full"
              >
                <video
                  autoPlay
                  muted
                  loop
                  className="h-full w-full object-cover  rounded-sm"
                >
                  <source src={videoSrc!} type="video/mp4" />
                </video>
              </motion.div>
            )}
          </motion.div>
        </>
        {createPortal(
          <AnimatePresence initial={false} mode="sync">
            {isMediaModalOpen && (
              <>
                <motion.div
                  key={`backdrop-${uniqueId}`}
                  className="fixed inset-0 h-full w-full dark:bg-black/25 bg-white/95 backdrop-blur-sm "
                  variants={{ open: { opacity: 1 }, closed: { opacity: 0 } }}
                  initial="closed"
                  animate="open"
                  exit="closed"
                  onClick={() => {
                    setIsMediaModalOpen(false)
                  }}
                />
                <motion.div
                  key="dialog"
                  className="pointer-events-none fixed inset-0 flex items-center justify-center z-50"
                >
                  <motion.div
                    className="pointer-events-auto relative flex flex-col overflow-hidden   dark:bg-gray-950 bg-gray-200 border w-[80%] h-[90%] "
                    layoutId={`dialog-${uniqueId}`}
                    tabIndex={-1}
                    style={{
                      borderRadius: '24px',
                    }}
                  >
                    {imgSrc && (
                      <motion.div
                        layoutId={`dialog-img-${uniqueId}`}
                        className="w-full h-full"
                      >
                        <img
                          src={imgSrc}
                          alt=""
                          className="h-full w-full object-cover"
                        />
                      </motion.div>
                    )}
                    {videoSrc && (
                      <motion.div
                        layoutId={`dialog-video-${uniqueId}`}
                        className="w-full h-full"
                      >
                        <video
                          autoPlay
                          muted
                          loop
                          controls
                          className="h-full w-full object-cover  rounded-sm"
                        >
                          <source src={videoSrc!} type="video/mp4" />
                        </video>
                      </motion.div>
                    )}
 
                    <button
                      onClick={() => setIsMediaModalOpen(false)}
                      className="absolute right-6 top-6 p-3 text-zinc-50 cursor-pointer dark:bg-gray-900 bg-gray-400 hover:bg-gray-500 rounded-full dark:hover:bg-gray-800"
                      type="button"
                      aria-label="Close dialog"
                    >
                      <XIcon size={24} />
                    </button>
                  </motion.div>
                </motion.div>
              </>
            )}
          </AnimatePresence>,
          document.body
        )}
      </MotionConfig>
    </>
  )
}

Props

PropTypeDefaultDescription
imgSrcstringundefinedOptional source URL for an image to display in the modal.
videoSrcstringundefinedOptional source URL for a video to display in the modal.
classNamestringundefinedOptional CSS class for styling the modal component.

Sometimes, we don't need reusable components because they are most useful when a component is used 2-3 times or more. However, in a single-page application, reusable components aren't always necessary. In such cases, you can use this component instead, and it will give you the same effect.

modal.tsx
'use client'
import { motion, AnimatePresence, MotionConfig } from 'framer-motion'
import { XIcon } from 'lucide-react'
import React, { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
 
const transition = {
  type: 'spring',
  duration: 0.4,
}
export default function Dialog() {
  const [index, setIndex] = useState(5)
  const [isOpen, setIsOpen] = useState(false)
  useEffect(() => {
    if (isOpen) {
      document.body.classList.add('overflow-hidden')
    } else {
      document.body.classList.remove('overflow-hidden')
    }
 
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        setIsOpen(false)
      }
    }
 
    document.addEventListener('keydown', handleKeyDown)
    return () => {
      document.removeEventListener('keydown', handleKeyDown)
    }
  }, [isOpen])
  type Item = {
    id: number
    imgSrc?: string
    videoSrc?: string
  }
 
  const items: Item[] = [
    {
      id: 1,
      imgSrc:
        'https://res.cloudinary.com/dzl9yxixg/image/upload/v1714558602/two_cosolk.jpg',
    },
    {
      id: 2,
 
      videoSrc:
        'https://res.cloudinary.com/dzl9yxixg/video/upload/v1715679908/nature_w14mmq.mp4',
    },
  ]
  const [carouselWidth, setCarouselWidth] = useState(0)
  const carousel = useRef(null)
  useEffect(() => {
    setCarouselWidth(
      // @ts-ignore
      carousel.current.scrollWidth - carousel.current.offsetWidth
    )
  }, [carousel])
  return (
    <div className="relative h-full">
      <MotionConfig transition={transition}>
        <motion.div
          ref={carousel}
          drag="x"
          dragElastic={0.2}
          dragConstraints={{ right: 0, left: -carouselWidth }}
          dragTransition={{ bounceDamping: 30 }}
          transition={{ duration: 8, ease: 'easeInOut' }}
          className="flex w-full  gap-4   py-10"
        >
          {items.map((item, i) => {
            return (
              <>
                <motion.div
                  // @ts-ignore
                  key={item}
                  className="w-full flex relative  flex-col overflow-hidden border    dark:bg-black bg-gray-300 hover:bg-gray-200 dark:hover:bg-gray-950"
                  layoutId={`dialog-${item?.id}`}
                  style={{
                    borderRadius: '12px',
                  }}
                  tabIndex={i}
                  onClick={() => {
                    setIndex(i)
                    setIsOpen(true)
                  }}
                >
                  {item?.imgSrc && (
                    <motion.div
                      layoutId={`dialog-img-${item.id}`}
                      className="w-full h-full"
                    >
                      <img
                        src={item.imgSrc}
                        alt="A desk lamp designed by Edouard Wilfrid Buquet in 1925. It features a double-arm design and is made from nickel-plated brass, aluminium and varnished wood."
                        className=" w-full object-cover"
                      />
                    </motion.div>
                  )}
                  {item?.videoSrc && (
                    <motion.div
                      layoutId={`dialog-video-${item.id}`}
                      className="w-full h-full"
                    >
                      <video
                        autoPlay
                        muted
                        loop
                        className="h-full w-full object-cover  rounded-sm"
                      >
                        <source src={item.videoSrc!} type="video/mp4" />
                      </video>
                    </motion.div>
                  )}
                </motion.div>
              </>
            )
          })}
        </motion.div>
 
        {createPortal(
          <AnimatePresence initial={false} mode="sync">
            {isOpen && (
              <>
                <motion.div
                  key={`backdrop-${items[index].id}`}
                  className="fixed inset-0 h-full w-full dark:bg-black/25 bg-white/95 backdrop-blur-sm "
                  variants={{ open: { opacity: 1 }, closed: { opacity: 0 } }}
                  initial="closed"
                  animate="open"
                  exit="closed"
                  onClick={() => {
                    setIsOpen(false)
                  }}
                />
                <motion.div
                  key="dialog"
                  className="pointer-events-none fixed inset-0 flex items-center justify-center z-50"
                >
                  <motion.div
                    className="pointer-events-auto relative flex flex-col overflow-hidden   dark:bg-gray-950 bg-gray-200 border w-[80%] h-[90%] "
                    layoutId={`dialog-${items[index].id}`}
                    tabIndex={-1}
                    style={{
                      borderRadius: '24px',
                    }}
                  >
                    {items[index]?.imgSrc && (
                      <motion.div
                        layoutId={`dialog-img-${items[index].id}`}
                        className="w-full h-full"
                      >
                        <img
                          src={items[index].imgSrc}
                          alt=""
                          className="h-full w-full object-cover"
                        />
                      </motion.div>
                    )}
                    {items[index]?.videoSrc && (
                      <motion.div
                        layoutId={`dialog-video-${items[index].id}`}
                        className="w-full h-full"
                      >
                        <video
                          autoPlay
                          muted
                          loop
                          controls
                          className="h-full w-full object-cover  rounded-sm"
                        >
                          <source
                            src={items[index].videoSrc!}
                            type="video/mp4"
                          />
                        </video>
                      </motion.div>
                    )}
 
                    <button
                      onClick={() => setIsOpen(false)}
                      className="absolute right-6 top-6 p-3 text-zinc-50 cursor-pointer dark:bg-gray-900 bg-gray-400 hover:bg-gray-500 rounded-full dark:hover:bg-gray-800"
                      type="button"
                      aria-label="Close dialog"
                    >
                      <XIcon size={24} />
                    </button>
                  </motion.div>
                </motion.div>
              </>
            )}
          </AnimatePresence>,
          document.body
        )}
      </MotionConfig>
    </div>
  )
}