Image Grayscale Hover Effect

Description

06.26c10v4 Image Grayscale Hover Effect

#1. Install Code

Note: code runs on Image Blocks only. See #2.5 to apply to Gallery Images, List Images

#1.1. Hover on page where you use Image Blocks > Click Gear icon

06.26c10v4 Image Grayscale Hover Effect

Click Advanced > Paste this code

<!-- 06.26c10v4 Image Grayscale Hover Effect -->
<script>
window.IMG_EFFECT_CONFIG = {
  sectionSelector: '#page',
  blackMaskAlpha: 0.15,  // Dark overlay opacity on grayscale state (0 = no overlay, 1 = fully black)
  enterDuration: 2.0,    // Duration in seconds for color reveal on mouse enter
  leaveDuration: 1.6,    // Duration in seconds for color fade out on mouse leave
  softEdge: 0.18,        // Softness of the reveal edge (0 = hard edge, higher = more feathered)
  enableWave: false,      // Enable/disable the ripple wave distortion effect (true / false)
  waveAmp: 0.012,        // Wave distortion strength (higher = more distortion)
  waveFreq: 18.0,        // Wave frequency — number of ripple rings (higher = more rings)
  waveSpeed: 3.5,        // Wave animation speed (higher = faster ripple)
  waveDecay: 3.5         // How quickly the wave fades with distance from mouse (higher = shorter wave reach)
};
</script>

<script>
(function() {
  var CONFIG = window.IMG_EFFECT_CONFIG;

  var VERT = `
    attribute vec2 a_position;
    attribute vec2 a_uv;
    varying vec2 v_uv;
    void main() {
      v_uv = a_uv;
      gl_Position = vec4(a_position, 0.0, 1.0);
    }
  `;

  var FRAG = `
    precision mediump float;
    uniform sampler2D u_texture;
    uniform float u_progress;
    uniform vec2 u_mouse;
    uniform float u_time;
    uniform float u_softEdge;
    uniform float u_blackAlpha;
    uniform float u_waveAmp;
    uniform float u_waveFreq;
    uniform float u_waveSpeed;
    uniform float u_waveDecay;
    varying vec2 v_uv;

    void main() {
      vec2 uv = v_uv;

      float dist = length(uv - u_mouse);

      float wave = u_waveAmp
        * exp(-u_waveDecay * dist)
        * sin(u_waveFreq * dist - u_waveSpeed * u_time)
        * u_progress
        * (1.0 - u_progress * 0.5);

      vec2 dir = normalize(uv - u_mouse + vec2(0.0001));
      uv += dir * wave;
      uv = clamp(uv, 0.0, 1.0);

      vec4 color = texture2D(u_texture, uv);
      float lum = dot(color.rgb, vec3(0.299, 0.587, 0.114));
      vec4 gray = vec4(vec3(lum), color.a);

      float dx = max(u_mouse.x, 1.0 - u_mouse.x);
      float dy = max(u_mouse.y, 1.0 - u_mouse.y);
      float maxDist = length(vec2(dx, dy)) * 1.05;

      float adjustedProgress = u_progress * maxDist;
      float mask = smoothstep(adjustedProgress, adjustedProgress - u_softEdge * maxDist, dist);

      vec4 revealed = mix(gray, color, mask);
      float darken = u_blackAlpha * (1.0 - mask * 0.8);
      revealed.rgb *= (1.0 - darken);

      gl_FragColor = revealed;
    }
  `;

  var allInstances = [];

  function compileShader(gl, type, src) {
    var s = gl.createShader(type);
    gl.shaderSource(s, src);
    gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
      console.warn('Shader error:', gl.getShaderInfoLog(s));
      gl.deleteShader(s);
      return null;
    }
    return s;
  }

  function createProgram(gl) {
    var vs = compileShader(gl, gl.VERTEX_SHADER, VERT);
    var fs = compileShader(gl, gl.FRAGMENT_SHADER, FRAG);
    if (!vs || !fs) return null;
    var prog = gl.createProgram();
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
      console.warn('Program link error:', gl.getProgramInfoLog(prog));
      return null;
    }
    return prog;
  }

  function init() {
    document.querySelectorAll(CONFIG.sectionSelector).forEach(function(section) {
      if (section.dataset.imgEffectDone) return;
      section.dataset.imgEffectDone = '1';
      section.querySelectorAll('img[data-sqsp-image-block-image]').forEach(function(img) {
        setupEffect(img);
      });
    });
  }

  function setupEffect(img) {
    var container = img.closest('.fluid-image-container');
    if (!container || container.dataset.glDone) return;
    container.dataset.glDone = '1';

    var canvas = document.createElement('canvas');
    canvas.style.cssText = [
      'position:absolute','top:0','left:0',
      'width:100%','height:100%',
      'pointer-events:none','z-index:2','display:block'
    ].join(';');

    var gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false });
    if (!gl) return;

    var prog = createProgram(gl);
    if (!prog) return;

    container.style.position = 'relative';
    container.appendChild(canvas);

    var quad = new Float32Array([
      -1,-1, 0,1,
       1,-1, 1,1,
      -1, 1, 0,0,
       1, 1, 1,0
    ]);
    var buf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
    gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW);

    var aPos = gl.getAttribLocation(prog, 'a_position');
    var aUV  = gl.getAttribLocation(prog, 'a_uv');
    var uTex      = gl.getUniformLocation(prog, 'u_texture');
    var uProgress = gl.getUniformLocation(prog, 'u_progress');
    var uMouse    = gl.getUniformLocation(prog, 'u_mouse');
    var uTime     = gl.getUniformLocation(prog, 'u_time');
    var uSoft     = gl.getUniformLocation(prog, 'u_softEdge');
    var uBlack    = gl.getUniformLocation(prog, 'u_blackAlpha');
    var uWAmp     = gl.getUniformLocation(prog, 'u_waveAmp');
    var uWFreq    = gl.getUniformLocation(prog, 'u_waveFreq');
    var uWSpeed   = gl.getUniformLocation(prog, 'u_waveSpeed');
    var uWDecay   = gl.getUniformLocation(prog, 'u_waveDecay');

    var texture = gl.createTexture();
    var textureReady = false;

    function loadTexture(src) {
      var image = new Image();
      image.crossOrigin = 'anonymous';
      image.onload = function() {
        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        textureReady = true;
        img.style.opacity = '0';
        renderFrame();
      };
      image.onerror = function() {
        var src2 = src.split('?')[0];
        if (src2 !== src) { image.src = src2; return; }
        console.warn('WebGL texture load failed');
      };
      image.src = src;
    }

    var imgSrc = img.src || img.getAttribute('data-src') || img.getAttribute('data-image');
    if (imgSrc) loadTexture(imgSrc);

    var state = {
      progress: 0,
      targetProgress: 0,
      startProgress: 0,
      startTime: 0,
      duration: CONFIG.enterDuration,
      rafId: null,
      mouseUV: [0.5, 0.5],
      time: 0,
      lastTs: 0
    };

    var instance = {
      forceLeave: function() {
        if (state.progress <= 0) return;
        if (state.rafId) cancelAnimationFrame(state.rafId);
        state.startProgress = state.progress;
        state.targetProgress = 0;
        state.duration = 0.3;
        state.startTime = 0;
        state.lastTs = 0;
        state.rafId = requestAnimationFrame(animate);
      }
    };
    allInstances.push(instance);

    function syncSize() {
      var rect = container.getBoundingClientRect();
      var dpr = window.devicePixelRatio || 1;
      var w = Math.round(rect.width * dpr);
      var h = Math.round(rect.height * dpr);
      if (canvas.width !== w || canvas.height !== h) {
        canvas.width = w;
        canvas.height = h;
        gl.viewport(0, 0, w, h);
      }
    }

    function renderFrame() {
      if (!textureReady) return;
      syncSize();
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.enable(gl.BLEND);
      gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
      gl.useProgram(prog);
      gl.bindBuffer(gl.ARRAY_BUFFER, buf);
      gl.enableVertexAttribArray(aPos);
      gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 16, 0);
      gl.enableVertexAttribArray(aUV);
      gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 16, 8);
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, texture);
      gl.uniform1i(uTex, 0);
      gl.uniform1f(uProgress, state.progress);
      gl.uniform2fv(uMouse, state.mouseUV);
      gl.uniform1f(uTime, state.time);
      gl.uniform1f(uSoft, CONFIG.softEdge);
      gl.uniform1f(uBlack, CONFIG.blackMaskAlpha);
      gl.uniform1f(uWAmp, CONFIG.enableWave ? CONFIG.waveAmp : 0.0);
      gl.uniform1f(uWFreq, CONFIG.waveFreq);
      gl.uniform1f(uWSpeed, CONFIG.waveSpeed);
      gl.uniform1f(uWDecay, CONFIG.waveDecay);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    }

    function easeOut(t) { return 1 - (1-t)*(1-t)*(1-t); }
    function easeInOut(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }

    function animate(ts) {
      var dt = state.lastTs ? (ts - state.lastTs) / 1000 : 0;
      state.lastTs = ts;
      state.time += dt;

      if (!state.startTime) state.startTime = ts;
      var raw = Math.min((ts - state.startTime) / (state.duration * 1000), 1);
      var easeFn = state.targetProgress >= state.startProgress ? easeOut : easeInOut;
      state.progress = state.startProgress + (state.targetProgress - state.startProgress) * easeFn(raw);

      renderFrame();

      var waveActive = CONFIG.enableWave && state.progress > 0 && state.progress < 1;
      if (raw < 1 || waveActive) {
        state.rafId = requestAnimationFrame(animate);
      } else {
        state.progress = state.targetProgress;
        renderFrame();
        state.rafId = null;
        state.lastTs = 0;
      }
    }

    function startAnim(target, dur) {
      if (state.rafId) cancelAnimationFrame(state.rafId);
      state.startProgress = state.progress;
      state.targetProgress = target;
      state.duration = dur;
      state.startTime = 0;
      state.lastTs = 0;
      state.rafId = requestAnimationFrame(animate);
    }

    function getUV(e) {
      var rect = container.getBoundingClientRect();
      var nw = img.naturalWidth, nh = img.naturalHeight;
      var cw = rect.width, ch = rect.height;
      var fx = 0, fy = 0, fw = cw, fh = ch;
      if (nw && nh) {
        var scale = Math.min(cw/nw, ch/nh);
        fw = nw*scale; fh = nh*scale;
        fx = (cw-fw)/2; fy = (ch-fh)/2;
      }
      return [
        Math.max(0, Math.min(1, (e.clientX - rect.left - fx) / fw)),
        Math.max(0, Math.min(1, (e.clientY - rect.top  - fy) / fh))
      ];
    }

    container.style.pointerEvents = 'auto';

    container.addEventListener('mouseenter', function(e) {
      allInstances.forEach(function(inst) {
        if (inst !== instance) inst.forceLeave();
      });
      state.mouseUV = getUV(e);
      startAnim(1, CONFIG.enterDuration);
    });

    container.addEventListener('mouseleave', function(e) {
      state.mouseUV = getUV(e);
      startAnim(0, CONFIG.leaveDuration);
    });

    container.addEventListener('mousemove', function(e) {
      state.mouseUV = getUV(e);
      if (!state.rafId && state.progress > 0) renderFrame();
    });

    window.addEventListener('resize', function() { syncSize(); renderFrame(); });

    syncSize();
    renderFrame();
  }

  function run() { init(); }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', run);
  } else {
    run();
  }

  window.addEventListener('mercury:load', function() {
    allInstances.length = 0;
    document.querySelectorAll(CONFIG.sectionSelector).forEach(function(s) {
      delete s.dataset.imgEffectDone;
      s.querySelectorAll('[data-gl-done]').forEach(function(el) {
        delete el.dataset.glDone;
      });
    });
    init();
  });
})();
</script>

06.26c10v4 Image Grayscale Hover Effect

#2. Customize

#2.1. To apply code to specific section, change ID in Line 04

sectionSelector: '#page',

#2.2. All options (Line 03 to Line 14)

window.IMG_EFFECT_CONFIG = {
  sectionSelector: '#page',
  blackMaskAlpha: 0.15,  // Dark overlay opacity on grayscale state (0 = no overlay, 1 = fully black)
  enterDuration: 2.0,    // Duration in seconds for color reveal on mouse enter
  leaveDuration: 1.6,    // Duration in seconds for color fade out on mouse leave
  softEdge: 0.18,        // Softness of the reveal edge (0 = hard edge, higher = more feathered)
  enableWave: false,      // Enable/disable the ripple wave distortion effect (true / false)
  waveAmp: 0.012,        // Wave distortion strength (higher = more distortion)
  waveFreq: 18.0,        // Wave frequency — number of ripple rings (higher = more rings)
  waveSpeed: 3.5,        // Wave animation speed (higher = faster ripple)
  waveDecay: 3.5         // How quickly the wave fades with distance from mouse (higher = shorter wave reach)
};

#2.3. If you want to apply code to all Image Blocks, you can use code to Code Injection > Footer

06.26c10v4 Image Grayscale Hover Effect

#2.4. If you see full color image appear about 1 second before the code runs, use this code to Custom CSS

#page .image-block {
  opacity: 0 !important;
}
#page .image-block:has(canvas) {
  opacity: 1 !important;
}

06.26c10v4 Image Grayscale Hover Effect

#2.5. To apply code on Image Blocks, Gallery Section, List Section, you can use this new code

<!-- 06.26c10v4 Image Grayscale Hover Effect -->
<script>
window.IMG_EFFECT_CONFIG = {
  sectionSelector: '#page',
  imageSelector: [
    'img[data-sqsp-image-block-image]',
    '.gallery-grid img',
    '.gallery-strips img',
    '.gallery-masonry img',
    '.list-item-media-inner img'
  ].join(', '),
  blackMaskAlpha: 0.15,
  enterDuration: 2.0,
  leaveDuration: 1.6,
  softEdge: 0.18,
  enableWave: false,
  waveAmp: 0.004,
  waveFreq: 14.0,
  waveSpeed: 3.5,
  waveDecay: 5.0,
  maxConcurrent: 8,
  idleReleaseDelay: 4
};
</script>
<script>
(function() {
  var CONFIG = window.IMG_EFFECT_CONFIG;
  if (!CONFIG) return;

  var VERT = `
    attribute vec2 a_position;
    attribute vec2 a_uv;
    varying vec2 v_uv;
    void main() {
      v_uv = a_uv;
      gl_Position = vec4(a_position, 0.0, 1.0);
    }
  `;

  var FRAG = `
    precision mediump float;
    uniform sampler2D u_texture;
    uniform float u_progress;
    uniform vec2 u_mouse;
    uniform float u_time;
    uniform float u_softEdge;
    uniform float u_blackAlpha;
    uniform float u_waveAmp;
    uniform float u_waveFreq;
    uniform float u_waveSpeed;
    uniform float u_waveDecay;
    uniform vec2 u_uvScale;
    uniform vec2 u_uvOffset;
    varying vec2 v_uv;

    void main() {
      vec2 uv = v_uv;
      float dist = length(uv - u_mouse);

      float wave = u_waveAmp
        * exp(-u_waveDecay * dist)
        * sin(u_waveFreq * dist - u_waveSpeed * u_time)
        * u_progress
        * (1.0 - u_progress)
        * 4.0;

      vec2 dir = normalize(uv - u_mouse + vec2(0.0001));
      uv += dir * wave;
      uv = clamp(uv, 0.0, 1.0);

      vec2 tuv = u_uvOffset + uv * u_uvScale;
      float inside = step(0.0, tuv.x) * step(tuv.x, 1.0) * step(0.0, tuv.y) * step(tuv.y, 1.0);

      vec4 color = texture2D(u_texture, clamp(tuv, 0.0, 1.0));
      float lum = dot(color.rgb, vec3(0.299, 0.587, 0.114));
      vec4 gray = vec4(vec3(lum), color.a);

      float dx = max(u_mouse.x, 1.0 - u_mouse.x);
      float dy = max(u_mouse.y, 1.0 - u_mouse.y);
      float maxDist = length(vec2(dx, dy)) * 1.05;

      float adjustedProgress = u_progress * maxDist;
      float mask = smoothstep(adjustedProgress, adjustedProgress - u_softEdge * maxDist, dist);

      vec4 revealed = mix(gray, color, mask);
      float darken = u_blackAlpha * (1.0 - mask * 0.8);
      revealed.rgb *= (1.0 - darken);

      gl_FragColor = vec4(revealed.rgb, revealed.a * inside);
    }
  `;

  var CONTAINER_CANDIDATES = [
    '.fluid-image-container',
    '.gallery-grid-item-wrapper',
    '.gallery-strips-item-wrapper',
    '.gallery-masonry-item-wrapper',
    '.list-item-media-inner'
  ];

  var FORMAT_SIZES = [300, 500, 750, 1000, 1500, 2500];
  var active = [];
  var globalBound = false;

  function injectStyle() {
    if (document.getElementById('img-effect-style')) return;
    var s = document.createElement('style');
    s.id = 'img-effect-style';
    s.textContent =
      '.img-effect-base{filter:grayscale(1) brightness(' + (1 - CONFIG.blackMaskAlpha) + ');}' +
      '.img-effect-canvas{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:2;display:block;}';
    document.head.appendChild(s);
  }

  function compileShader(gl, type, src) {
    var s = gl.createShader(type);
    gl.shaderSource(s, src);
    gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
      console.warn('Shader error:', gl.getShaderInfoLog(s));
      gl.deleteShader(s);
      return null;
    }
    return s;
  }

  function createProgram(gl) {
    var vs = compileShader(gl, gl.VERTEX_SHADER, VERT);
    var fs = compileShader(gl, gl.FRAGMENT_SHADER, FRAG);
    if (!vs || !fs) return null;
    var prog = gl.createProgram();
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
      console.warn('Program link error:', gl.getProgramInfoLog(prog));
      return null;
    }
    return prog;
  }

  function findContainer(img) {
    for (var i = 0; i < CONTAINER_CANDIDATES.length; i++) {
      var c = img.closest(CONTAINER_CANDIDATES[i]);
      if (c) return c;
    }
    return img.parentElement;
  }

  function pickFormat(w) {
    for (var i = 0; i < FORMAT_SIZES.length; i++) {
      if (FORMAT_SIZES[i] >= w) return FORMAT_SIZES[i] + 'w';
    }
    return '2500w';
  }

  function resolveSrc(img, container) {
    var raw = img.currentSrc || img.src || img.getAttribute('data-src') || img.getAttribute('data-image') || '';
    if (raw.indexOf('data:') === 0) {
      raw = img.getAttribute('data-src') || img.getAttribute('data-image') || '';
    }
    if (!raw) return null;
    var base = raw.split('?')[0];
    if (base.indexOf('squarespace') === -1) return raw;
    var dpr = Math.min(window.devicePixelRatio || 1, 2);
    var w = (container.getBoundingClientRect().width || 800) * dpr;
    return base + '?format=' + pickFormat(w);
  }

  function easeOut(t) { return 1 - (1 - t) * (1 - t) * (1 - t); }
  function easeInOut(t) { return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; }

  function release(inst) {
    if (inst.released) return;
    inst.released = true;
    if (inst.releaseTimer) clearTimeout(inst.releaseTimer);
    if (inst.state.rafId) cancelAnimationFrame(inst.state.rafId);
    var ext = null;
    try { ext = inst.gl.getExtension('WEBGL_lose_context'); } catch (e) {}
    if (ext) ext.loseContext();
    if (inst.canvas.parentNode) inst.canvas.parentNode.removeChild(inst.canvas);
    inst.ctrl.inst = null;
    var i = active.indexOf(inst);
    if (i > -1) active.splice(i, 1);
  }

  function acquire(ctrl) {
    if (active.length >= (CONFIG.maxConcurrent || 8)) {
      var victim = null;
      for (var i = 0; i < active.length; i++) {
        var it = active[i];
        if (!it.ctrl.hover && it.state.progress === 0 && !it.state.rafId) { victim = it; break; }
      }
      if (!victim) {
        for (i = 0; i < active.length; i++) {
          if (!active[i].ctrl.hover) { victim = active[i]; break; }
        }
      }
      if (victim) release(victim);
    }
    var inst = createInstance(ctrl);
    if (inst) active.push(inst);
    return inst;
  }

  function createInstance(ctrl) {
    var img = ctrl.img;
    var container = ctrl.container;

    var canvas = document.createElement('canvas');
    canvas.className = 'img-effect-canvas';
    var gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false });
    if (!gl) return null;
    var prog = createProgram(gl);
    if (!prog) return null;
    container.appendChild(canvas);

    var quad = new Float32Array([
      -1, -1, 0, 1,
       1, -1, 1, 1,
      -1,  1, 0, 0,
       1,  1, 1, 0
    ]);
    var buf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
    gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW);

    var aPos = gl.getAttribLocation(prog, 'a_position');
    var aUV  = gl.getAttribLocation(prog, 'a_uv');
    var uTex      = gl.getUniformLocation(prog, 'u_texture');
    var uProgress = gl.getUniformLocation(prog, 'u_progress');
    var uMouse    = gl.getUniformLocation(prog, 'u_mouse');
    var uTime     = gl.getUniformLocation(prog, 'u_time');
    var uSoft     = gl.getUniformLocation(prog, 'u_softEdge');
    var uBlack    = gl.getUniformLocation(prog, 'u_blackAlpha');
    var uWAmp     = gl.getUniformLocation(prog, 'u_waveAmp');
    var uWFreq    = gl.getUniformLocation(prog, 'u_waveFreq');
    var uWSpeed   = gl.getUniformLocation(prog, 'u_waveSpeed');
    var uWDecay   = gl.getUniformLocation(prog, 'u_waveDecay');
    var uUvScale  = gl.getUniformLocation(prog, 'u_uvScale');
    var uUvOffset = gl.getUniformLocation(prog, 'u_uvOffset');

    var texture = gl.createTexture();

    var state = {
      progress: 0,
      targetProgress: 0,
      startProgress: 0,
      startTime: 0,
      duration: CONFIG.enterDuration,
      rafId: null,
      mouseUV: [0.5, 0.5],
      time: 0,
      lastTs: 0
    };

    var inst = {
      ctrl: ctrl,
      canvas: canvas,
      gl: gl,
      state: state,
      ready: false,
      released: false,
      releaseTimer: null,
      texW: 0,
      texH: 0
    };

    function syncSize() {
      var rect = container.getBoundingClientRect();
      var dpr = window.devicePixelRatio || 1;
      var w = Math.round(rect.width * dpr);
      var h = Math.round(rect.height * dpr);
      if (canvas.width !== w || canvas.height !== h) {
        canvas.width = w;
        canvas.height = h;
        gl.viewport(0, 0, w, h);
      }
    }

    function computeFit() {
      var rect = container.getBoundingClientRect();
      var cw = rect.width || 1, ch = rect.height || 1;
      var nw = inst.texW || cw, nh = inst.texH || ch;
      var fit = getComputedStyle(img).objectFit || 'fill';
      if (fit === 'fill' || fit === 'inherit' || fit === 'initial' || fit === 'unset' || fit === '') {
        return { scale: [1, 1], offset: [0, 0] };
      }
      var s;
      if (fit === 'contain') s = Math.min(cw / nw, ch / nh);
      else if (fit === 'none') s = 1;
      else if (fit === 'scale-down') s = Math.min(Math.min(cw / nw, ch / nh), 1);
      else s = Math.max(cw / nw, ch / nh);
      var fw = nw * s, fh = nh * s;
      var fx = (cw - fw) / 2, fy = (ch - fh) / 2;
      return { scale: [cw / fw, ch / fh], offset: [-fx / fw, -fy / fh] };
    }

    function renderFrame() {
      if (!inst.ready || inst.released) return;
      syncSize();
      var fit = computeFit();
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.enable(gl.BLEND);
      gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
      gl.useProgram(prog);
      gl.bindBuffer(gl.ARRAY_BUFFER, buf);
      gl.enableVertexAttribArray(aPos);
      gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 16, 0);
      gl.enableVertexAttribArray(aUV);
      gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 16, 8);
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, texture);
      gl.uniform1i(uTex, 0);
      gl.uniform1f(uProgress, state.progress);
      gl.uniform2fv(uMouse, state.mouseUV);
      gl.uniform1f(uTime, state.time);
      gl.uniform1f(uSoft, CONFIG.softEdge);
      gl.uniform1f(uBlack, CONFIG.blackMaskAlpha);
      gl.uniform1f(uWAmp, CONFIG.enableWave ? CONFIG.waveAmp : 0.0);
      gl.uniform1f(uWFreq, CONFIG.waveFreq);
      gl.uniform1f(uWSpeed, CONFIG.waveSpeed);
      gl.uniform1f(uWDecay, CONFIG.waveDecay);
      gl.uniform2fv(uUvScale, fit.scale);
      gl.uniform2fv(uUvOffset, fit.offset);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    }

    function scheduleRelease() {
      if (inst.releaseTimer) clearTimeout(inst.releaseTimer);
      inst.releaseTimer = setTimeout(function() { release(inst); }, (CONFIG.idleReleaseDelay || 4) * 1000);
    }

    function animate(ts) {
      var dt = state.lastTs ? (ts - state.lastTs) / 1000 : 0;
      state.lastTs = ts;
      state.time += dt;

      if (!state.startTime) state.startTime = ts;
      var raw = Math.min((ts - state.startTime) / (state.duration * 1000), 1);
      var easeFn = state.targetProgress >= state.startProgress ? easeOut : easeInOut;
      state.progress = state.startProgress + (state.targetProgress - state.startProgress) * easeFn(raw);

      renderFrame();

      var waveActive = CONFIG.enableWave && state.progress > 0 && state.progress < 1;
      if (raw < 1 || waveActive) {
        state.rafId = requestAnimationFrame(animate);
      } else {
        state.progress = state.targetProgress;
        renderFrame();
        state.rafId = null;
        state.lastTs = 0;
        if (state.progress === 0 && !ctrl.hover) scheduleRelease();
      }
    }

    function startAnim(target, dur) {
      if (inst.releaseTimer) { clearTimeout(inst.releaseTimer); inst.releaseTimer = null; }
      if (state.rafId) cancelAnimationFrame(state.rafId);
      state.startProgress = state.progress;
      state.targetProgress = target;
      state.duration = dur;
      state.startTime = 0;
      state.lastTs = 0;
      state.rafId = requestAnimationFrame(animate);
    }

    inst.syncSize = syncSize;
    inst.renderFrame = renderFrame;
    inst.startAnim = startAnim;
    inst.forceLeave = function() {
      if (!inst.ready || state.progress <= 0) return;
      startAnim(0, 0.3);
    };

    function loadTexture() {
      var src = resolveSrc(img, container);
      if (!src) { release(inst); return; }
      var tried = 0;
      var image = new Image();
      image.crossOrigin = 'anonymous';
      image.onload = function() {
        if (inst.released) return;
        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        inst.texW = image.naturalWidth;
        inst.texH = image.naturalHeight;
        inst.ready = true;
        if (ctrl.hover) startAnim(1, CONFIG.enterDuration);
        else scheduleRelease();
      };
      image.onerror = function() {
        tried++;
        if (tried === 1) {
          image.src = (img.currentSrc || img.src || src).split('?')[0];
          return;
        }
        console.warn('WebGL texture load failed');
        release(inst);
      };
      image.src = src;
    }

    canvas.addEventListener('webglcontextlost', function(e) {
      e.preventDefault();
      release(inst);
    });

    loadTexture();
    syncSize();
    return inst;
  }

  function getUV(container, e) {
    var rect = container.getBoundingClientRect();
    return [
      Math.max(0, Math.min(1, (e.clientX - rect.left) / (rect.width || 1))),
      Math.max(0, Math.min(1, (e.clientY - rect.top) / (rect.height || 1)))
    ];
  }

  function forceLeaveOthers(ctrl) {
    active.forEach(function(inst) {
      if (inst.ctrl !== ctrl) inst.forceLeave();
    });
  }

  function setupTarget(img) {
    var container = findContainer(img);
    if (!container || container.dataset.imgFxDone) return;
    container.dataset.imgFxDone = '1';
    container.style.position = 'relative';
    container.style.pointerEvents = 'auto';
    img.classList.add('img-effect-base');

    var ctrl = { img: img, container: container, inst: null, hover: false };

    container.addEventListener('mouseenter', function(e) {
      ctrl.hover = true;
      forceLeaveOthers(ctrl);
      if (!ctrl.inst) ctrl.inst = acquire(ctrl);
      if (ctrl.inst) {
        ctrl.inst.state.mouseUV = getUV(container, e);
        if (ctrl.inst.ready) ctrl.inst.startAnim(1, CONFIG.enterDuration);
      }
    });

    container.addEventListener('mouseleave', function(e) {
      ctrl.hover = false;
      if (ctrl.inst) {
        ctrl.inst.state.mouseUV = getUV(container, e);
        if (ctrl.inst.ready) ctrl.inst.startAnim(0, CONFIG.leaveDuration);
      }
    });

    container.addEventListener('mousemove', function(e) {
      if (ctrl.inst && ctrl.inst.ready) {
        ctrl.inst.state.mouseUV = getUV(container, e);
        if (!ctrl.inst.state.rafId && ctrl.inst.state.progress > 0) ctrl.inst.renderFrame();
      }
    });
  }

  function init() {
    injectStyle();
    document.querySelectorAll(CONFIG.sectionSelector).forEach(function(scope) {
      scope.querySelectorAll(CONFIG.imageSelector).forEach(setupTarget);
    });
    if (!globalBound) {
      globalBound = true;
      window.addEventListener('resize', function() {
        active.forEach(function(inst) {
          if (inst.ready) { inst.syncSize(); inst.renderFrame(); }
        });
      });
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
  window.addEventListener('load', init);

  window.addEventListener('mercury:load', function() {
    active.slice().forEach(release);
    document.querySelectorAll('[data-img-fx-done]').forEach(function(el) {
      delete el.dataset.imgFxDone;
    });
    init();
  });
})();
</script>

 

Buy me a coffee