logo

Nurav UI

Community
Background & Effects

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.

Loading...

Variant: Standard Grid

A classic responsive grid layout for displaying sets of images.

Loading...

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 layoutId for Apple-like smooth expansions.
  • Multiple Views: Choose from standard grid, asymmetrical bento, or staggered masonry.
  • Intelligent Focus: Automatically locks body scroll and supports closing via the Escape key.

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:

components.json
{
  "registries": {
    "@nurav-ui": "https://nurav-ui.vercel.app/r"
  }
}

Or skip alias setup entirely and use the direct URL below.

Terminal
npx shadcn@latest add @nurav-ui/morphing-gallery

Alternatively, install via direct URL (no components.json changes needed):

Terminal
npx shadcn@latest add https://nurav-ui.vercel.app/r/morphing-gallery.json

Manual Setup

If you prefer manual configuration, follow these steps:

Install Dependencies

Install the necessary animation and icon libraries:

Terminal
npm install motion lucide-react clsx tailwind-merge

Create/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.

MorphingGallery.tsx
"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

PropTypeDefaultDescription
itemsrequired
MorphingGalleryItem[][]Array of image objects to display.
variant
'grid' | 'bento' | 'masonry''grid'The layout style.
className
stringundefinedAdditional block-level classes.

MorphingGalleryItem

PropTypeDefaultDescription
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.

Built by Varun

Last Updated:April 10, 2026

On this page