Components / Hover Gallery

Hover Gallery

An interactive image gallery with smooth layout animations. Images are stacked by default and expand into a 2x2 grid on hover with beautiful transitions.

Preview

Hover over the gallery to see the images expand into a grid layout.

Gallery item 1
Gallery item 2
Gallery item 3
Gallery item 4

Usage

tsx
import HoverGallery from "@/components/hover-gallery/hover-gallery"

// First 3 images for the stacked view
const stackedImages = [
  { id: 1, bg: "#22c55e", img: "/images/photo1.jpg" },
  { id: 2, bg: "#ef4444", img: "/images/photo2.jpg" },
  { id: 3, bg: "#eab308", img: "/images/photo3.jpg" },
]

// 4th image for the bottom
const bottomImage = { id: 4, bg: "#3b82f6", img: "/images/photo4.jpg" }

export function Example() {
  return (
    <HoverGallery
      stackedImages={stackedImages}
      bottomImage={bottomImage}
      width="420px"
      height="450px"
    />
  )
}

Props

PropTypeDefaultDescription
stackedImagesElement[]defaultStackedImagesFirst 3 images for stacked view
bottomImageElementdefaultBottomImage4th image shown at bottom
widthstring"420px"Container width (CSS value)
heightstring"450px"Container height (CSS value)
classNamestring-Custom class for wrapper

Element Type

PropertyTypeDescription
idnumberUnique identifier for layout animations
bgstringBackground color (fallback)
imgstringImage source URL

Features

  • Smooth layout animations powered by Framer Motion
  • Stacked card view with rotation effects (-12°, 0°, +12°)
  • 2x2 grid expansion on hover
  • Shared layoutId for seamless transitions
  • Customizable container dimensions
  • Theme-aware with card and border styling
  • Background color fallback for images

Animation Logic

  • Default state: 3 cards stacked with rotation + 1 at bottom
  • On hover: Stacked view fades out (opacity 0)
  • Grid view fades in with layoutId transition
  • Cards animate from their stacked positions to grid positions
  • Uses AnimatePresence with popLayout mode

Dependencies

bash
npm install motion

Source Code

tsx
"use client";

import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";

type Element = {
    id: number;
    bg: string;
    img: string;
};

export interface HoverGalleryProps {
    /** First 3 stacked images */
    stackedImages?: Element[];
    /** 4th bottom image */
    bottomImage?: Element;
    /** Container width */
    width?: string;
    /** Container height */
    height?: string;
    /** Custom class name */
    className?: string;
}

const defaultStackedImages: Element[] = [
    { id: 1, bg: "#22c55e", img: "/albums/blonde.png" },
    { id: 2, bg: "#ef4444", img: "/images/college-dropout.jpg" },
    { id: 3, bg: "#eab308", img: "/images/graduation.jpg" },
];

const defaultBottomImage: Element = { id: 4, bg: "#3b82f6", img: "/images/mbdtf.jpg" };

const Gallery = (props: { item: Element; index?: number }) => {
    return (
        <motion.div
            className={cn(
                "rounded-2xl flex items-center justify-center h-[80%] w-[80%] max-h-[222px] origin-bottom overflow-hidden"
            )}
            layoutId={`box-${props.item.id}`}
            animate={{
                rotate: props.index === 0 ? -12 : props.index === 2 ? 12 : undefined,
            }}
            style={{
                gridRow: 1,
                gridColumn: 1,
                backgroundColor: props.item.bg,
            }}
        >
            <img src={props.item.img} alt={`Gallery item ${props.item.id}`} className="w-full h-full object-cover" />
        </motion.div>
    );
};

const HoverGallery = ({
    stackedImages = defaultStackedImages,
    bottomImage = defaultBottomImage,
    width = "420px",
    height = "450px",
    className,
}: HoverGalleryProps) => {
    const [isHovering, setIsHovering] = useState(false);
    
    const items = [
        {
            elements: stackedImages,
        },
    ];

    const allElements = items.flatMap((column) => column.elements);

    const elements = [
        ...allElements,
        bottomImage,
    ];

    return (
        <div className={cn("w-full h-full flex items-center justify-center", className)}>
            <motion.div
                onMouseEnter={() => setIsHovering(true)}
                onMouseLeave={() => setIsHovering(false)}
                className="rounded-[30px] px-4 py-5 relative overflow-hidden bg-card border border-border/50"
                style={{ width, height }}
            >
                <motion.div
                    className="w-full h-full gap-2 flex flex-col items-start justify-center"
                    layout
                    transition={{ duration: 0.5, ease: "easeInOut" }}
                >
                    {items.map((column, index) => (
                        <motion.div
                            className={cn(
                                "flex flex-col items-center justify-center gap-10 w-full h-full"
                            )}
                            key={index}
                            layout
                            animate={{
                                opacity: isHovering ? 0 : 1,
                                willChange: "auto",
                            }}
                        >
                            <div className="rounded-2xl cursor-pointer grid place-items-center flex-[2] w-full">
                                {column.elements.map((item, index) => (
                                    <Gallery item={item} index={index} key={index} />
                                ))}
                            </div>
                            <motion.div
                                className={cn(
                                    "rounded-2xl cursor-pointer flex items-center justify-center overflow-hidden flex-1 w-full h-full"
                                )}
                                layoutId={`box-${bottomImage.id}`}
                            >
                                <img
                                    src={bottomImage.img}
                                    alt={`Gallery item ${bottomImage.id}`}
                                    className="w-full h-full object-cover"
                                />
                            </motion.div>
                        </motion.div>
                    ))}
                </motion.div>
                {isHovering && (
                    <motion.div
                        initial={{ opacity: 0 }}
                        animate={{ opacity: 1, willChange: "auto" }}
                        transition={{ duration: 0.5, ease: "easeInOut" }}
                        className="absolute inset-0 w-full h-full overflow-hidden"
                    >
                        <AnimatePresence mode="popLayout">
                            <motion.div
                                className="grid grid-cols-2 gap-4 justify-center items-center h-full w-full p-4"
                                animate={{ opacity: 1, y: 0 }}
                                transition={{ delay: 0.3 }}
                            >
                                {elements.map(({ id, bg, img }, index) => (
                                    <motion.div
                                        key={index}
                                        className={cn(
                                            "rounded-2xl cursor-pointer flex items-center justify-center overflow-hidden h-full"
                                        )}
                                        layoutId={`box-${id}`}
                                    >
                                        <img
                                            src={img}
                                            alt={`Gallery item ${id}`}
                                            className="w-full h-full object-cover"
                                        />
                                    </motion.div>
                                ))}
                            </motion.div>
                        </AnimatePresence>
                    </motion.div>
                )}
            </motion.div>
        </div>
    );
};

export default HoverGallery;
export { Gallery };
export type { Element as GalleryItem };