Image modal

Expand an image into a focused, draggable full-screen view.

Source code

Copy and paste into your project
ImageModal.tsx
"use client";
 
import { useEffect, useState } from "react";
import { AnimatePresence, LayoutGroup, animate, motion, useMotionValue, useTransform } from "framer-motion";
import Image from "next/image";
 
type Props = {
src: string;
alt?: string;
layoutId?: string;
className?: string;
};
 
export default function ImageModal({ src, alt = '', layoutId, className = '' }: Props) {
const [open, setOpen] = useState(false);
const id = layoutId || `img-modal-${src.slice(-10)}`;
const image = open ? { src, alt } : null;
const onClose = () => setOpen(false);
 
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
if (image) document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [image]);
 
useEffect(() => {
if (!image) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [image]);
 
const closeIfDragged = (offset: { x: number; y: number }, velocity: { x: number; y: number }) => {
const threshold = 140;
const velocityThreshold = 650;
const didClose =
Math.abs(offset.x) > threshold ||
Math.abs(offset.y) > threshold ||
Math.abs(velocity.x) > velocityThreshold ||
Math.abs(velocity.y) > velocityThreshold;
return didClose;
};
 
const viewTransition = { duration: 0.32, ease: [0.22, 1, 0.36, 1] as const };
 
const mvX = useMotionValue(0);
const mvY = useMotionValue(0);
const rotateZ = useTransform(mvX, [-300, 300], [-2.5, 2.5]);
const rotateX = useTransform(mvY, [-300, 300], [2.5, -2.5]);
 
// Reset motion values when modal closes
useEffect(() => {
if (!open) {
mvX.set(0);
mvY.set(0);
}
}, [open, mvX, mvY]);
 
return (
<LayoutGroup id={`image-modal-${id}`}>
<motion.button
layoutId={id}
type="button"
onClick={() => setOpen(true)}
className={`relative h-48 w-full cursor-pointer overflow-hidden rounded-lg bg-neutral-100 transition-shadow hover:shadow-lg dark:bg-neutral-800 sm:h-56 md:h-64 ${className}`}
aria-label={`Open ${alt}`}
whileHover={{ scale: 1.01 }}
transition={viewTransition}
>
<Image
src={src}
alt={alt}
fill
className="object-cover"
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, 400px"
priority
/>
</motion.button>
 
<AnimatePresence>
{image && (
<>
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="fixed inset-0 bg-black/40 z-[60] backdrop-blur-md cursor-pointer"
onClick={onClose}
/>
 
<motion.div
key="container"
className="pointer-events-none fixed inset-0 z-[70] flex items-center justify-center p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
>
<motion.div
drag
style={{
x: mvX,
y: mvY,
rotateZ,
rotateX,
}}
dragConstraints={{ top: -90, left: -90, right: 90, bottom: 90 }}
dragElastic={0.22}
onDragEnd={(_, info) => {
const shouldClose = closeIfDragged(info.offset, info.velocity);
if (shouldClose) {
animate(mvX, info.offset.x + info.velocity.x * 80, { duration: 0.16, ease: "easeOut" });
animate(mvY, info.offset.y + info.velocity.y * 80, { duration: 0.16, ease: "easeOut" });
setTimeout(() => {
mvX.set(0);
mvY.set(0);
onClose();
}, 160);
} else {
animate(mvX, 0, { duration: 0.22, ease: [0.22, 1, 0.36, 1] });
animate(mvY, 0, { duration: 0.22, ease: [0.22, 1, 0.36, 1] });
}
}}
whileDrag={{ scale: 1.01 }}
className="pointer-events-auto cursor-grab touch-none [perspective:1200px] [will-change:transform] active:cursor-grabbing"
onClick={(e) => e.stopPropagation()}
>
<motion.div
layoutId={id}
transition={viewTransition}
className="aspect-video w-[min(900px,92vw)] max-h-[90vh] overflow-hidden rounded-lg bg-white shadow-sm dark:bg-neutral-900"
>
<div className="relative h-full w-full bg-neutral-100 dark:bg-neutral-800">
<Image
src={image.src}
alt={image.alt ?? ""}
fill
sizes="(max-width: 640px) 90vw, 900px"
className="object-cover min-h-full min-w-full bg-transparent select-none cursor-grab active:cursor-grabbing"
draggable={false}
onDragStart={(e) => e.preventDefault()}
priority
/>
</div>
</motion.div>
</motion.div>
</motion.div>
</>
)}
</AnimatePresence>
</LayoutGroup>
);
}