Description
- custom image effect
- view demo – password: abc
- buy me a coffee

#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

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>

#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

#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;
}

#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>