Mountain landscape

Morphogenesis

Solid form gives way to liquid movement

This animation technique combines WebGL shaders with scroll-triggered events to create an organic dissolve effect. The noise-based algorithm generates unique patterns that reveal content as you scroll through the page.

Earth from space

Digital horizons stretching beyond perception

Forest sunlight

Nature transforms through light and shadow

Keep scrolling to see the autoplay effect...

Abstract gradient

Autoplay Mode

This animation plays automatically when you scroll into view

Keep scrolling to see the image reveal effect...

Mountain lakeImage reveals from white cover

Another reveal example with dark background...

Foggy mountainsRepeats every time you scroll back (data-dissolve-repeat)

1. CDN Scripts

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

<!-- Three.js CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/0.160.0/three.min.js"></script>

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

2. Required CSS

[data-dissolve] {
position: relative;
width: 100%;
min-height: 100vh;
overflow: hidden;
}

[data-dissolve] .dissolve-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}

[data-dissolve] .dissolve-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 2;
}

[data-dissolve] .dissolve-content {
position: relative;
z-index: 3;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100%;
padding: 2rem;
}

3. HTML Structure

<section
data-dissolve
data-dissolve-image="your-image.jpg"
data-dissolve-color="#ebf5df"
data-dissolve-spread="0.5"
data-dissolve-speed="2"
>
<img class="dissolve-image" src="your-image.jpg" alt="Description">
<canvas class="dissolve-canvas"></canvas>
<div class="dissolve-content">
<!-- Your content here -->
<h1>Your Heading</h1>
<p>Your description text</p>
</div>
</section>

4. Data Attributes Reference

AttributeDefaultDescription
data-dissolverequiredEnables the dissolve animation on this section
data-dissolve-imagenoneURL of the background image (should match img src)
data-dissolve-color#ebf5dfColor of the dissolve overlay effect
data-dissolve-spread0.5Amount of noise spread (0.1 - 1.0). Higher = more organic edges
data-dissolve-speed2Animation speed multiplier (scroll mode only). Higher = faster dissolve
data-dissolve-autoplaydisabledEnable autoplay mode - animation plays once when section enters viewport
data-dissolve-duration1.5Duration in seconds for autoplay animation
data-dissolve-delay0Delay in seconds before autoplay animation starts
data-dissolve-easepower2.outGSAP easing function for autoplay animation
data-dissolve-revealdisabledUse instead of data-dissolve for centered image reveal mode (cover dissolves to reveal image)
data-dissolve-repeatdisabledReplay animation every time element scrolls into view (instead of playing once)

5. JavaScript

Add this script at the end of your <body> tag:

gsap.registerPlugin(ScrollTrigger);

// Vertex Shader
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;

// Fragment Shader
const fragmentShader = `
uniform float uProgress;
uniform vec2 uResolution;
uniform vec3 uColor;
uniform float uSpread;
varying vec2 vUv;

float Hash(vec2 p) {
vec3 p2 = vec3(p.xy, 1.0);
return fract(sin(dot(p2, vec3(37.1, 61.7, 12.4))) * 3758.5453123);
}

float noise(in vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f *= f * (3.0 - 2.0 * f);
return mix(
mix(Hash(i + vec2(0.0, 0.0)), Hash(i + vec2(1.0, 0.0)), f.x),
mix(Hash(i + vec2(0.0, 1.0)), Hash(i + vec2(1.0, 1.0)), f.x),
f.y
);
}

float fbm(vec2 p) {
float v = 0.0;
v += noise(p * 1.0) * 0.5;
v += noise(p * 2.0) * 0.25;
v += noise(p * 4.0) * 0.125;
return v;
}

void main() {
vec2 uv = vUv;
float aspect = uResolution.x / uResolution.y;
vec2 centeredUv = (uv - 0.5) * vec2(aspect, 1.0);

float dissolveEdge = uv.y - uProgress * 1.2;
float noiseValue = fbm(centeredUv * 15.0);
float d = dissolveEdge + noiseValue * uSpread;

float pixelSize = 1.0 / uResolution.y;
float alpha = 1.0 - smoothstep(-pixelSize, pixelSize, d);

gl_FragColor = vec4(uColor, alpha);
}
`;

// Helper: Convert hex color to RGB (0-1 range)
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16) / 255,
g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255,
}
: { r: 0.92, g: 0.96, b: 0.87 };
}

// Initialize all dissolve sections
document.querySelectorAll("[data-dissolve]").forEach((section) => {
// Get configuration from data attributes
const color = section.dataset.dissolveColor || "#ebf5df";
const spread = parseFloat(section.dataset.dissolveSpread) || 0.5;
const speed = parseFloat(section.dataset.dissolveSpeed) || 2;
const isAutoplay = section.hasAttribute("data-dissolve-autoplay");
const isRepeat = section.hasAttribute("data-dissolve-repeat");
const duration = parseFloat(section.dataset.dissolveDuration) || 1.5;
const delay = parseFloat(section.dataset.dissolveDelay) || 0;
const ease = section.dataset.dissolveEase || "power2.out";

const canvas = section.querySelector(".dissolve-canvas");
if (!canvas) return;

// Three.js setup
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: false,
});

// Resize handler
function resize() {
const width = section.offsetWidth;
const height = section.offsetHeight;
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
material.uniforms.uResolution.value.set(width, height);
}

// Create shader material
const rgb = hexToRgb(color);
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uProgress: { value: 0 },
uResolution: {
value: new THREE.Vector2(section.offsetWidth, section.offsetHeight),
},
uColor: { value: new THREE.Vector3(rgb.r, rgb.g, rgb.b) },
uSpread: { value: spread },
},
transparent: true,
});

const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

resize();
window.addEventListener("resize", resize);

// Animation state
let progressValue = { value: 0 };

function animate() {
material.uniforms.uProgress.value = progressValue.value;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}

animate();

if (isAutoplay) {
// Autoplay mode: animate when section enters viewport
ScrollTrigger.create({
trigger: section,
start: "top 80%",
end: "bottom 20%",
once: !isRepeat,
onEnter: () => {
gsap.to(progressValue, { value: 1.1, duration, delay, ease });
},
onEnterBack: isRepeat ? () => {
gsap.to(progressValue, { value: 1.1, duration, delay, ease });
} : undefined,
onLeave: isRepeat ? () => {
gsap.to(progressValue, { value: 0, duration: 0.3 });
} : undefined,
onLeaveBack: isRepeat ? () => {
gsap.to(progressValue, { value: 0, duration: 0.3 });
} : undefined,
});
} else {
// Scroll-driven mode: progress tied to scroll position
ScrollTrigger.create({
trigger: section,
start: "top top",
end: "bottom top",
onUpdate: (self) => {
progressValue.value = Math.min(self.progress * speed, 1.1);
},
});
}
});

// Initialize all reveal sections (centered image that reveals from cover)
document.querySelectorAll("[data-dissolve-reveal]").forEach((section) => {
const color = section.dataset.dissolveColor || "#ffffff";
const spread = parseFloat(section.dataset.dissolveSpread) || 0.5;
const isRepeat = section.hasAttribute("data-dissolve-repeat");
const duration = parseFloat(section.dataset.dissolveDuration) || 1.5;
const delay = parseFloat(section.dataset.dissolveDelay) || 0;
const ease = section.dataset.dissolveEase || "power2.out";

const container = section.querySelector(".reveal-container");
const canvas = section.querySelector(".reveal-canvas");
if (!canvas || !container) return;

const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: false });

const rgb = hexToRgb(color);
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uProgress: { value: 1.1 }, // Start fully covered
uResolution: { value: new THREE.Vector2(container.offsetWidth, container.offsetHeight) },
uColor: { value: new THREE.Vector3(rgb.r, rgb.g, rgb.b) },
uSpread: { value: spread },
},
transparent: true,
});

const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

function resize() {
const width = container.offsetWidth;
const height = container.offsetHeight;
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
material.uniforms.uResolution.value.set(width, height);
}

resize();
window.addEventListener("resize", resize);

let progressValue = { value: 1.1 };

function animate() {
material.uniforms.uProgress.value = progressValue.value;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}

animate();

// Reveal mode: animate from 1.1 to 0 when section enters viewport
if (isRepeat) {
// Repeat mode: replay animation every time element enters/leaves viewport
ScrollTrigger.create({
trigger: section,
start: "top 80%",
end: "bottom 20%",
onEnter: () => {
gsap.to(progressValue, { value: 0, duration, delay, ease });
},
onEnterBack: () => {
gsap.to(progressValue, { value: 0, duration, delay, ease });
},
onLeave: () => {
gsap.set(progressValue, { value: 1.1 });
},
onLeaveBack: () => {
gsap.set(progressValue, { value: 1.1 });
},
});
} else {
// Play once mode
ScrollTrigger.create({
trigger: section,
start: "top 80%",
once: true,
onEnter: () => {
gsap.to(progressValue, { value: 0, duration, delay, ease });
},
});
}
});

6. Usage Tips for Webstudio

7. Customization Examples

<!-- Slow, subtle dissolve (scroll-driven) -->
<section
data-dissolve
data-dissolve-image="image.jpg"
data-dissolve-color="#ffffff"
data-dissolve-spread="0.3"
data-dissolve-speed="1"
>...</section>

<!-- Fast, dramatic dissolve (scroll-driven) -->
<section
data-dissolve
data-dissolve-image="image.jpg"
data-dissolve-color="#ff0000"
data-dissolve-spread="0.8"
data-dissolve-speed="3"
>...</section>

<!-- Match your brand color -->
<section
data-dissolve
data-dissolve-image="image.jpg"
data-dissolve-color="#your-brand-color"
data-dissolve-spread="0.5"
data-dissolve-speed="2"
>...</section>

8. Autoplay Mode (Play on Scroll Into View)

Use data-dissolve-autoplay to trigger the animation automatically when the section scrolls into view:

<!-- Basic autoplay -->
<section
data-dissolve
data-dissolve-image="image.jpg"
data-dissolve-color="#f97316"
data-dissolve-spread="0.5"
data-dissolve-autoplay
>...</section>

<!-- Autoplay with custom duration (2 seconds) -->
<section
data-dissolve
data-dissolve-image="image.jpg"
data-dissolve-color="#8b5cf6"
data-dissolve-spread="0.6"
data-dissolve-autoplay
data-dissolve-duration="2"
>...</section>

<!-- Autoplay with delay and custom easing -->
<section
data-dissolve
data-dissolve-image="image.jpg"
data-dissolve-color="#ec4899"
data-dissolve-spread="0.5"
data-dissolve-autoplay
data-dissolve-duration="1.5"
data-dissolve-delay="0.3"
data-dissolve-ease="power4.out"
>...</section>

<!-- Autoplay with REPEAT (plays every time you scroll into view) -->
<section
data-dissolve
data-dissolve-image="image.jpg"
data-dissolve-color="#06b6d4"
data-dissolve-spread="0.5"
data-dissolve-autoplay
data-dissolve-duration="1.5"
data-dissolve-repeat
>...</section>

Play Once vs Repeat:

9. Image Reveal Mode (Centered Container)

Use data-dissolve-reveal for a centered, smaller image that reveals from a solid color cover. This is the opposite of the dissolve effect - it starts with a colored cover and reveals the image underneath.

Required CSS for Reveal Mode

[data-dissolve-reveal] {
position: relative;
width: 100%;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}

[data-dissolve-reveal] .reveal-container {
position: relative;
width: 80%;
max-width: 900px;
aspect-ratio: 16 / 10;
overflow: hidden;
border-radius: 12px;
}

[data-dissolve-reveal] .reveal-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}

[data-dissolve-reveal] .reveal-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 2;
}

HTML Structure for Reveal Mode

<!-- White background, image reveals -->
<section
class="demo-reveal"
data-dissolve-reveal
data-dissolve-color="#ffffff"
data-dissolve-spread="0.5"
data-dissolve-autoplay
data-dissolve-duration="1.8"
>
<div class="reveal-container">
<img class="reveal-image" src="your-image.jpg" alt="Description">
<canvas class="reveal-canvas"></canvas>
</div>
</section>

<!-- Dark background variant -->
<section
style="background-color: #1a1a1a;"
data-dissolve-reveal
data-dissolve-color="#1a1a1a"
data-dissolve-spread="0.6"
data-dissolve-autoplay
data-dissolve-duration="2"
>
<div class="reveal-container">
<img class="reveal-image" src="your-image.jpg" alt="Description">
<canvas class="reveal-canvas"></canvas>
</div>
</section>

Key Differences from Dissolve Mode