
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.


Keep scrolling to see the autoplay effect...

This animation plays automatically when you scroll into view
Keep scrolling to see the image reveal effect...
Image reveals from white coverAnother reveal example with dark background...
Repeats every time you scroll back (data-dissolve-repeat)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>[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;
}<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>| Attribute | Default | Description |
|---|---|---|
data-dissolve | required | Enables the dissolve animation on this section |
data-dissolve-image | none | URL of the background image (should match img src) |
data-dissolve-color | #ebf5df | Color of the dissolve overlay effect |
data-dissolve-spread | 0.5 | Amount of noise spread (0.1 - 1.0). Higher = more organic edges |
data-dissolve-speed | 2 | Animation speed multiplier (scroll mode only). Higher = faster dissolve |
data-dissolve-autoplay | disabled | Enable autoplay mode - animation plays once when section enters viewport |
data-dissolve-duration | 1.5 | Duration in seconds for autoplay animation |
data-dissolve-delay | 0 | Delay in seconds before autoplay animation starts |
data-dissolve-ease | power2.out | GSAP easing function for autoplay animation |
data-dissolve-reveal | disabled | Use instead of data-dissolve for centered image reveal mode (cover dissolves to reveal image) |
data-dissolve-repeat | disabled | Replay animation every time element scrolls into view (instead of playing once) |
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 });
},
});
}
});data-dissolve-image matches the <img> srcmin-height: 150vh for scroll room).dissolve-content div holds your visible content (headings, text, etc.)data-dissolve-speed based on your section height<!-- 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>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:
data-dissolve-repeat: Animation replays every time you scroll back up and re-enterUse 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.
[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;
}<!-- 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>data-dissolve-reveal instead of data-dissolve.reveal-container.reveal-image and .reveal-canvas instead of dissolve variantsdata-dissolve-color to your section background for seamless effect