Image Reveal Component

A cinematic image reveal where a 10×15 grid of tiles dissolves away in a diagonal wave, uncovering the photograph beneath.

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

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) using gridTemplateColumns 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>
The overlay carries 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 its row + 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
The multiplier 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 their initial 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