
I wanted to recreate the kind of dramatic image reveal you see on high-end portfolio and product launch sites — where a photo isn't simply faded in but actively uncovered, tile by tile, like a curtain being drawn back. The whole effect is built with a CSS Grid overlay and Framer Motion — no canvas, no WebGL, no external animation libraries beyond what's already in the stack.The overlay carries The multiplier
Building the Grid Overlay
The core idea is to place an absolutely-positioned CSS grid directly on top of the image. Each cell starts fully opaque white — completely hiding the image — and later animates to transparent when the reveal fires. The container is divided into a 10 rows × 15 cols grid (150 tiles total) usinggridTemplateColumns and gridTemplateRows set inline from JavaScript constants, so changing the grid density is a one-line change.
const rows = 10;
const cols = 15;
<div
key={isRevealed ? "revealed" : "hidden"}
className="absolute inset-0 z-10 pointer-events-none grid h-full w-full"
style={{
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
}}
>
{[...Array(rows * cols)].map((_, i) => {
const row = Math.floor(i / cols);
const col = i % cols;
// render each tile...
})}
</div>
pointer-events-none so it never blocks hover interactions on the image below, and z-10 to ensure it sits above the image layer at all times.Animating Each Tile with Framer Motion
Every cell is a<motion.div>. Its initial state is fully opaque white. When isRevealed flips to true, the animate prop drives each tile to opacity: 0 with a subtle scale: 1.1 pop before vanishing — adding a small sense of physicality to each departing tile. Setting filter: brightness(2) on the initial state gives tiles a luminous, glowing quality rather than flat white, making the pre-reveal feel intentional.
<motion.div
key={i}
initial={{
opacity: 1,
filter: "brightness(2)",
background: "rgba(255,255,255,1)",
}}
animate={
isRevealed
? {
opacity: 0,
filter: "brightness(1)",
background: "rgba(255,255,255,0)",
scale: 1.1,
}
: {}
}
transition={{
duration: 0.6,
ease: "easeInOut",
delay: (row + col) * 0.04, // diagonal wave
}}
className="w-full h-full relative border-[0.5px] border-white/5"
/>
The Diagonal Wave Delay
The one detail that turns a plain fade into a real sweep is the staggered delay. Rather than all 150 tiles disappearing at once, each tile's delay is derived from itsrow + col sum. Tiles near the top-left disappear first; tiles at the bottom-right disappear last — producing a natural diagonal wave across the entire image.
// Each cell's delay is computed from its row + column index.
// This produces a diagonal sweep from top-left → bottom-right.
delay: (row + col) * 0.04
// For a 10 × 15 grid the maximum diagonal index is (9 + 14) = 23
// Maximum delay → 23 × 0.04 = 0.92 s
// Per-tile duration → 0.6 s
// Total visible reveal ≈ 0.92 + 0.6 = ~1.5 s
0.04 is fully tunable — increase it for a slow theatrical wipe, decrease it for a snappy explosive reveal.Resetting the Animation
A subtle challenge with Framer Motion is getting animated elements back to theirinitial state cleanly after they've already animated. The cleanest fix is changing the grid wrapper's key prop whenever the toggle fires. When React sees a new key it discards and fully remounts the component tree — so all 150 tiles snap back to their white initial state instantly, zero leftover animation state to fight.
// Force a full remount of the grid when toggling state.
// This snaps every tile back to its white 'initial' state instantly
// without fighting Framer Motion's internal animation cache.
<div key={isRevealed ? "revealed" : "hidden"} ...>
Full Source
"use client";
import Image from "next/image";
import React, { useState } from "react";
import { motion } from "framer-motion";
function ImageReveal() {
const [isRevealed, setIsRevealed] = useState(false);
const rows = 10;
const cols = 15;
return (
<>
<div className="relative w-full max-w-4xl aspect-video rounded-2xl overflow-hidden shadow-2xl border border-white/10 group">
<div
key={isRevealed ? "revealed" : "hidden"}
className="absolute inset-0 z-10 pointer-events-none grid h-full w-full"
style={{
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
}}
>
{[...Array(rows * cols)].map((_, i) => {
const row = Math.floor(i / cols);
const col = i % cols;
return (
<motion.div
key={i}
initial={{
opacity: 1,
filter: "brightness(2)",
background: "rgba(255,255,255,1)",
}}
animate={
isRevealed
? {
opacity: 0,
filter: "brightness(1)",
background: "rgba(255,255,255,0)",
scale: 1.1,
}
: {}
}
transition={{
duration: 0.6,
ease: "easeInOut",
delay: (row + col) * 0.04,
}}
className="w-full h-full relative border-[0.5px] border-white/5"
/>
);
})}
</div>
<Image
src="/home/bridge.jpg"
className="object-cover w-full h-full transform transition-transform duration-700 group-hover:scale-105"
width={1200}
height={800}
alt="image"
priority
/>
</div>
<button
onClick={() => setIsRevealed(!isRevealed)}
className="group relative px-10 py-4 bg-white text-black font-semibold rounded-full overflow-hidden hover:scale-105 active:scale-95 transition-all duration-300"
>
<span className="relative z-10 flex items-center gap-2">
{isRevealed ? "Reset Gallery" : "Ignite Reveal"}
</span>
</button>
</>
);
}
export default ImageReveal;
Here's the source code, I hope you like it :)
prash240303/crafts/ImageReveal