Morphing Gallery
A stunning image gallery with smooth layout-morphing animations and portal-based detail overlays.Copy Markdown
Preview
Variant: Bento Grid
A modern, asymmetrical grid layout inspired by bento-style design with an elegant detail overlay.
Variant: Standard Grid
A classic responsive grid layout for displaying sets of images.
Features
- Portal overlay: Fixes z-index clipping issues by rendering the expanded card at the document body level.
- Top-tier Animations: Leverages Framer Motion's
layoutIdfor Apple-like smooth expansions. - Multiple Views: Choose from standard
grid, asymmetricalbento, or staggeredmasonry. - Intelligent Focus: Automatically locks body scroll and supports closing via the
Escapekey.
Installation
Automatic (CLI)
The fastest way to get started is using the shadcn CLI.
Before using the @nurav-ui alias, add this to your components.json:
{
"registries": {
"@nurav-ui": "https://nurav-ui.vercel.app/r"
}
}Or skip alias setup entirely and use the direct URL below.
npx shadcn@latest add @nurav-ui/morphing-galleryAlternatively, install via direct URL (no components.json changes needed):
npx shadcn@latest add https://nurav-ui.vercel.app/r/morphing-gallery.jsonManual Setup
If you prefer manual configuration, follow these steps:
Install Dependencies
Install the necessary animation and icon libraries:
npm install motion lucide-react clsx tailwind-mergeCreate/Customize Component
Copy the following code into your project (e.g., components/nurav-ui/MorphingGallery.tsx). This implementation uses React Portals to escape stacking contexts.
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "@/lib/utils";
import { X, ZoomIn, ArrowUpRight } from "lucide-react";
export interface MorphingGalleryItem {
id: string;
src: string;
alt?: string;
title?: string;
description?: string;
tag?: string;
/** Custom Tailwind col/row span classes for bento layout */
className?: string;
}
export interface MorphingGalleryProps {
items: MorphingGalleryItem[];
variant?: "grid" | "bento" | "masonry";
className?: string;
}
const BENTO_PATTERNS = [
"md:col-span-2 md:row-span-2",
"md:col-span-2",
"",
"",
"md:col-span-2",
"",
"",
];
export function MorphingGallery({
items,
variant = "grid",
className,
}: MorphingGalleryProps) {
const [selected, setSelected] = useState<MorphingGalleryItem | null>(null);
const [mounted, setMounted] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
// Lock body scroll when expanded
useEffect(() => {
if (selected) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [selected]);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setSelected(null);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
const getGridClass = () => {
switch (variant) {
case "bento":
return "grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 auto-rows-[200px] md:auto-rows-[260px]";
case "masonry":
return "columns-1 sm:columns-2 md:columns-3 gap-4 space-y-4";
default:
return "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 auto-rows-[260px]";
}
};
const getItemClass = (index: number, item: MorphingGalleryItem) => {
if (variant === "bento") {
return cn(
"relative cursor-pointer overflow-hidden group w-full h-full",
item.className ?? BENTO_PATTERNS[index % BENTO_PATTERNS.length],
);
}
if (variant === "masonry") {
return "relative cursor-pointer overflow-hidden group break-inside-avoid mb-4";
}
return "relative cursor-pointer overflow-hidden group w-full h-full";
};
const modal =
selected &&
mounted &&
createPortal(
<AnimatePresence>
<div
className="fixed inset-0 flex items-center justify-center p-4 md:p-10"
style={{ zIndex: 9999 }}
>
{/* Backdrop */}
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
onClick={() => setSelected(null)}
className="absolute inset-0 bg-foreground/5 backdrop-blur-[10px]"
/>
{/* Morphing card */}
<motion.div
key={`modal-${selected.id}`}
layoutId={`item-${selected.id}`}
className="relative w-full max-w-5xl bg-background rounded-3xl overflow-hidden shadow-sm border border-foreground/10"
style={{ zIndex: 10000 }}
transition={{ type: "spring", bounce: 0.15, duration: 0.5 }}
>
{/* Close button */}
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
onClick={(e) => {
e.stopPropagation();
setSelected(null);
}}
className="absolute top-4 right-4 z-50 flex items-center justify-center w-9 h-9 rounded-full bg-background text-foreground/90 backdrop-blur-md transition-colors"
>
<X size={16} />
</motion.button>
<div className="flex flex-col md:flex-row h-[80vh] md:h-[75vh]">
{/* Image */}
<div className="flex-3 relative bg-background/90 min-h-[45vw] md:min-h-0 overflow-hidden">
<motion.img
layoutId={`img-${selected.id}`}
src={selected.src}
alt={selected.alt ?? ""}
className="w-full h-full object-cover"
/>
{/* Tag chip */}
{selected.tag && (
<motion.span
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="absolute top-4 left-4 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-widest bg-background backdrop-blur-sm border border-foreground/10"
>
{selected.tag}
</motion.span>
)}
</div>
{/* Detail panel */}
<div className="flex-1 flex flex-col justify-between p-6 md:p-10 overflow-y-auto bg-background border-t md:border-t-0 md:border-l border-foreground/10">
<div>
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-xs font-bold uppercase tracking-widest text-foreground/50 mb-2"
>
{selected.tag ?? "Photo"}
</motion.p>
<motion.h2
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="text-2xl md:text-3xl font-bold tracking-tight text-foreground/90 mb-4 leading-tight"
>
{selected.title ?? "Untitled"}
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="text-sm text-foreground/40 leading-relaxed"
>
{selected.description ?? "No description available."}
</motion.p>
</div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.35 }}
className="flex gap-3 mt-8"
>
<button className="flex items-center gap-2 px-5 py-2.5 bg-background text-foreground/70 hover:text-foreground/90 rounded-xl text-sm font-semibold hover:opacity-85 transition-opacity">
<ArrowUpRight size={14} />
Open full
</button>
<button className="px-5 py-2.5 border border-foreground/10 rounded-xl text-sm font-semibold text-foreground/70 hover:text-foreground/90 transition-colors">
Share
</button>
</motion.div>
</div>
</div>
</motion.div>
</div>
</AnimatePresence>,
document.body,
);
return (
<>
<div ref={containerRef} className={cn("w-full", className)}>
<div className={getGridClass()}>
<AnimatePresence>
{items.map((item, index) => (
<motion.div
key={item.id}
layoutId={`item-${item.id}`}
onClick={() => setSelected(item)}
className={getItemClass(index, item)}
whileHover={{ scale: variant === "bento" ? 1 : 1.02 }}
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
>
<motion.img
layoutId={`img-${item.id}`}
src={item.src}
alt={item.alt ?? ""}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-linear-to-t from-black/70 via-black/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
{/* Hover info */}
<div className="absolute bottom-0 left-0 right-0 p-4 translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-300">
<div className="flex items-end justify-between">
<div>
{item.tag && (
<span className="text-[10px] uppercase tracking-widest font-bold text-foreground/30 block mb-0.5">
{item.tag}
</span>
)}
{item.title && (
<p className="text-white font-semibold text-sm leading-tight">
{item.title}
</p>
)}
</div>
<div className="w-7 h-7 rounded-full bg-foreground/10 backdrop-blur-sm border border-foreground/10 flex items-center justify-center shrink-0 ml-2">
<ZoomIn size={12} className="text-white" />
</div>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
{modal}
</>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | MorphingGalleryItem[] | [] | Array of image objects to display. |
variant | 'grid' | 'bento' | 'masonry' | 'grid' | The layout style. |
className | string | undefined | Additional block-level classes. |
MorphingGalleryItem
| Prop | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | Unique identifier used for Framer Motion `layoutId`. |
srcrequired | string | — | Image source URL. |
alt | string | — | Alt text for the image. |
title | string | — | Big title on modal, or overlay. |
description | string | — | Paragraph description in the expanded view. |
tag | string | — | Brief tag like "Photo", "Nature", "Update". |
className | string | — | Applied to the grid item wrapper, useful for custom col/row spans. |