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 lake Image reveals from white cover

Another reveal example with dark background...

Foggy mountains Repeats 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

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)

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