A WebGL-powered image reveal effect using Three.js and GSAP ScrollTrigger. Images reveal with a randomized grid pattern as you scroll.
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>
.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;
}
<!-- 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"
/>
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}
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));
}
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();
});
| 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 |