Image Grid Reveal Animation

A WebGL-powered image reveal effect using Three.js and GSAP ScrollTrigger. Images reveal with a randomized grid pattern as you scroll.

Image Grid

Foggy mountains
Forest path
Waterfall
Lake reflection

1. CDN Scripts

Add these to your <head> or before closing </body> tag:

<!-- Three.js -->
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>

<!-- GSAP + ScrollTrigger -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>

2. Required CSS

.webgl-canvas {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 10;
}

[data-image-reveal] {
  opacity: 0;
  visibility: hidden;
}

3. HTML Structure

<!-- Add canvas at the top of body -->
<canvas class="webgl-canvas"></canvas>

<!-- Add data-image-reveal to any image -->
<img
  data-image-reveal
  data-reveal-color="#fe0100"
  data-grid-size="20"
  src="your-image.jpg"
  alt="Description"
/>

4. Vertex Shader

varying vec2 vUv;

void main() {
  vUv = uv;
  gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}

5. Fragment Shader

uniform sampler2D uTexture;
uniform vec2 uResolution;
uniform vec2 uContainerRes;
uniform float uProgress;
uniform float uGridSize;
uniform vec3 uColor;
varying vec2 vUv;

float random(vec2 st) {
  return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}

vec2 squaresGrid(vec2 vUv) {
  float containerAspectX = uResolution.x / uResolution.y;
  float containerAspectY = uResolution.y / uResolution.x;
  vec2 ratio = vec2(
    min(containerAspectX, 1.0),
    min(containerAspectY, 1.0)
  );
  return vec2(
    vUv.x * ratio.x + (1.0 - ratio.x) * 0.5,
    vUv.y * ratio.y + (1.0 - ratio.y) * 0.5
  );
}

void main() {
  // Cover UVs (maintain aspect ratio)
  float imageAspectX = uResolution.x / uResolution.y;
  float imageAspectY = uResolution.y / uResolution.x;
  float containerAspectX = uContainerRes.x / uContainerRes.y;
  float containerAspectY = uContainerRes.y / uContainerRes.x;
  vec2 ratio = vec2(
    min(containerAspectX / imageAspectX, 1.0),
    min(containerAspectY / imageAspectY, 1.0)
  );
  vec2 coverUvs = vec2(
    vUv.x * ratio.x + (1.0 - ratio.x) * 0.5,
    vUv.y * ratio.y + (1.0 - ratio.y) * 0.5
  );

  // Generate grid
  vec2 squareUvs = squaresGrid(coverUvs);
  float gridSize = floor(uContainerRes.x / uGridSize);
  vec2 grid = vec2(
    floor(squareUvs.x * gridSize) / gridSize,
    floor(squareUvs.y * gridSize) / gridSize
  );
  vec4 gridTexture = vec4(uColor, 0.0);

  // Image texture
  vec4 texture = texture2D(uTexture, coverUvs);
  float height = 0.2;
  float progress = (1.0 + height) - (uProgress * (1.0 + height + height));
  float dist = 1.0 - distance(grid.y, progress);
  float clampedDist = smoothstep(height, 0.0, distance(grid.y, progress));
  float randDist = step(1.0 - height * random(grid), dist);
  dist = step(1.0 - height, dist);
  float rand = random(grid);
  float alpha = dist * (clampedDist + rand - 0.5 * (1.0 - randDist));
  alpha = max(0.0, alpha);
  gridTexture.a = alpha;
  texture.rgba *= step(progress, grid.y);

  gl_FragColor = vec4(mix(texture, gridTexture, gridTexture.a));
}

6. JavaScript

gsap.registerPlugin(ScrollTrigger);

class ImageRevealEffect {
  constructor() {
    this.canvas = document.querySelector('.webgl-canvas');
    this.scene = new THREE.Scene();
    this.camera = new THREE.OrthographicCamera();
    this.renderer = new THREE.WebGLRenderer({
      canvas: this.canvas,
      alpha: true,
      antialias: true
    });
    this.medias = [];
    this.init();
  }

  init() {
    this.setCamera();
    this.setRenderer();
    this.createMedias();
    this.addEventListeners();
    this.render();
  }

  setCamera() {
    this.sizes = {
      width: window.innerWidth,
      height: window.innerHeight
    };
    this.camera.left = -this.sizes.width / 2;
    this.camera.right = this.sizes.width / 2;
    this.camera.top = this.sizes.height / 2;
    this.camera.bottom = -this.sizes.height / 2;
    this.camera.near = -1000;
    this.camera.far = 1000;
    this.camera.updateProjectionMatrix();
  }

  setRenderer() {
    this.renderer.setSize(this.sizes.width, this.sizes.height);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  }

  createMedias() {
    const images = document.querySelectorAll('[data-image-reveal]');
    images.forEach(element => {
      const media = new MediaPlane({
        element,
        scene: this.scene,
        sizes: this.sizes
      });
      this.medias.push(media);
    });
  }

  addEventListeners() {
    window.addEventListener('resize', () => {
      this.setCamera();
      this.setRenderer();
      this.medias.forEach(media => media.onResize(this.sizes));
    });

    window.addEventListener('scroll', () => {
      this.medias.forEach(media => media.updateScroll(window.scrollY));
    });
  }

  render() {
    this.renderer.render(this.scene, this.camera);
    requestAnimationFrame(() => this.render());
  }
}

class MediaPlane {
  constructor({ element, scene, sizes }) {
    this.element = element;
    this.scene = scene;
    this.sizes = sizes;
    this.currentScroll = 0;
    this.lastScroll = 0;

    this.revealColor = element.dataset.revealColor || '#242424';
    this.gridSize = parseFloat(element.dataset.gridSize) || 20;

    this.createGeometry();
    this.createMaterial();
    this.createMesh();
    this.setBounds();
    this.setTexture();
    this.observe();

    this.scene.add(this.mesh);
  }

  createGeometry() {
    this.geometry = new THREE.PlaneGeometry(1, 1, 1, 1);
  }

  createMaterial() {
    this.material = new THREE.ShaderMaterial({
      vertexShader: vertexShader,
      fragmentShader: fragmentShader,
      uniforms: {
        uTexture: { value: null },
        uResolution: { value: new THREE.Vector2(0, 0) },
        uContainerRes: { value: new THREE.Vector2(0, 0) },
        uProgress: { value: 0 },
        uGridSize: { value: this.gridSize },
        uColor: { value: new THREE.Color(this.revealColor) }
      },
      transparent: true
    });
  }

  createMesh() {
    this.mesh = new THREE.Mesh(this.geometry, this.material);
  }

  setBounds() {
    const bounds = this.element.getBoundingClientRect();
    this.mesh.scale.x = (bounds.width * this.sizes.width) / window.innerWidth;
    this.mesh.scale.y = (bounds.height * this.sizes.height) / window.innerHeight;

    this.mesh.position.x = (bounds.left * this.sizes.width) / window.innerWidth
      - this.sizes.width / 2 + this.mesh.scale.x / 2;
    this.mesh.position.y = (-bounds.top * this.sizes.height) / window.innerHeight
      + this.sizes.height / 2 - this.mesh.scale.y / 2;

    this.material.uniforms.uContainerRes.value.set(bounds.width, bounds.height);
  }

  setTexture() {
    new THREE.TextureLoader().load(this.element.src, (texture) => {
      this.material.uniforms.uTexture.value = texture;
      this.material.uniforms.uResolution.value.set(
        texture.image.naturalWidth,
        texture.image.naturalHeight
      );
    });
  }

  observe() {
    gsap.to(this.material.uniforms.uProgress, {
      value: 1,
      duration: 1.6,
      ease: 'none',
      scrollTrigger: {
        trigger: this.element,
        start: 'top bottom',
        end: 'bottom top',
        toggleActions: 'play reset restart reset'
      }
    });
  }

  updateScroll(scrollY) {
    const delta = ((-scrollY * this.sizes.height) / window.innerHeight) - this.lastScroll;
    this.lastScroll = (-scrollY * this.sizes.height) / window.innerHeight;
    this.mesh.position.y -= delta;
  }

  onResize(sizes) {
    this.sizes = sizes;
    this.setBounds();
  }
}

// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
  new ImageRevealEffect();
});

7. Data Attributes Reference

Attribute Default Description
data-image-reveal required Enables the WebGL reveal effect
data-reveal-color #242424 Color of the grid squares during reveal
data-grid-size 20 Size of grid squares (lower = larger squares)
data-play-once false Animation plays only once, won't replay when scrolling back
Note: This effect uses WebGL shaders to create a grid-based reveal animation. The image is hidden by default and rendered on a Three.js canvas that overlays the page. As you scroll, the grid squares animate to reveal the image with a randomized pattern.