Skip to content

Instantly share code, notes, and snippets.

@merttoka
Created February 18, 2026 22:18
Show Gist options
  • Select an option

  • Save merttoka/848821ab957bedf8354334b2af01f88d to your computer and use it in GitHub Desktop.

Select an option

Save merttoka/848821ab957bedf8354334b2af01f88d to your computer and use it in GitHub Desktop.
Fluid displacement image hover
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebGL Hover — Displacement Component</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: 'Manrope', sans-serif;
font-size: 14px;
line-height: 28px;
background-color: #fcfcfc;
color: #111;
-webkit-font-smoothing: antialiased;
}
::selection {
background: #BF1656;
color: white;
text-shadow: none;
}
a, a:visited {
color: #BF1656;
text-decoration: none;
}
a:hover, a:focus { color: #111; }
/* ── Layout ─────────────────────────────────────────────── */
.page {
width: 90%;
max-width: 1500px;
margin: 0 auto;
padding: 80px 0 120px;
}
header {
margin-bottom: 60px;
}
header .label {
font-size: 12px;
letter-spacing: 0.5px;
text-transform: uppercase;
color: #888;
margin-bottom: 10px;
font-weight: 500;
}
header h1 {
font-family: 'Manrope', sans-serif;
font-size: clamp(28px, 4vw, 40px);
font-weight: 600;
letter-spacing: -1px;
line-height: 1.1;
color: #111;
}
header h1 em {
font-style: normal;
color: #BF1656;
}
header p {
margin-top: 16px;
font-size: 14px;
color: #888;
line-height: 1.6;
max-width: 540px;
}
/* ── Grid ───────────────────────────────────────────────── */
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 40px 30px;
}
@media screen and (max-width: 1200px) {
.grid { grid-template-columns: repeat(3, 1fr); }
}
@media screen and (max-width: 900px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@media screen and (max-width: 600px) {
.grid { grid-template-columns: 1fr; gap: 30px; }
}
/* ── Project card ───────────────────────────────────────── */
.project-card {
display: block;
text-decoration: none;
color: inherit;
position: relative;
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.project-card:hover {
transform: translateY(-5px);
}
.project-card .thumb-img {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 4px;
filter: grayscale(100%);
transition: filter 0.4s ease;
background: #f0f0f0;
}
.project-card:hover .thumb-img {
filter: grayscale(0%);
}
.project-card canvas {
position: absolute;
inset: 0;
width: 100% !important;
aspect-ratio: 1 / 1;
border-radius: 4px;
pointer-events: none;
display: block;
}
@media (prefers-reduced-motion: reduce) {
.project-card canvas { display: none !important; }
}
/* Card meta */
.card-meta {
margin-top: 10px;
display: flex;
flex-direction: column;
}
.card-meta .tag {
font-size: 12px;
color: #888;
font-weight: 400;
letter-spacing: 0.5px;
text-transform: uppercase;
margin-top: 5px;
line-height: 1.5;
}
.card-meta h2 {
font-family: 'Manrope', sans-serif;
font-size: 16px;
font-weight: 600;
line-height: 1.2;
color: #222;
transition: color 0.3s ease;
}
.project-card:hover .card-meta h2 {
color: #BF1656;
}
.card-meta p {
font-size: 13px;
color: #888;
line-height: 1.5;
margin-top: 4px;
}
/* ── Docs block ─────────────────────────────────────────── */
.docs {
margin-top: 80px;
border-top: 1px solid #f0f0f0;
padding-top: 48px;
}
.docs h3 {
font-family: 'Manrope', sans-serif;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
color: #888;
margin-bottom: 24px;
}
.tradeoffs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 24px;
}
.tradeoff {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 4px;
padding: 20px;
}
.tradeoff .icon {
font-size: 18px;
margin-bottom: 10px;
}
.tradeoff strong {
display: block;
font-family: 'Manrope', sans-serif;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
color: #222;
}
.tradeoff p {
font-size: 12px;
color: #888;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="page">
<header>
<div class="label">Experiment / WebGL</div>
<h1>Fluid displacement<br><em>image hover</em></h1>
<p>
Three.js + custom GLSL fragment shader. RAF pauses when idle.
Semantic &lt;img&gt; fallback. Respects prefers-reduced-motion.
</p>
</header>
<div class="grid" id="grid">
<a href="#" class="project-card"
data-img="https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=800&q=80"
data-disp="https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&q=80">
<img class="thumb-img"
src="https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=800&q=80"
alt="Generative growth simulation — SimulacraNaturae" />
<div class="card-meta">
<h2>Phantom Topology</h2>
<span class="tag">Fabrication · Generative</span>
</div>
</a>
<a href="#" class="project-card"
data-img="https://images.unsplash.com/photo-1620641788421-7a1c342ea42e?w=800&q=80"
data-disp="https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&q=80">
<img class="thumb-img"
src="https://images.unsplash.com/photo-1620641788421-7a1c342ea42e?w=800&q=80"
alt="DynamoVis — movement ecology visualization software" />
<div class="card-meta">
<h2>Liquid Archive</h2>
<span class="tag">Research · Visualization</span>
</div>
</a>
<a href="#" class="project-card"
data-img="https://images.unsplash.com/photo-1550745165-9bc0b252726f?w=800&q=80"
data-disp="https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&q=80">
<img class="thumb-img"
src="https://images.unsplash.com/photo-1550745165-9bc0b252726f?w=800&q=80"
alt="Global Organoid Orchestra — distributed media installation" />
<div class="card-meta">
<h2>Neon Drift</h2>
<span class="tag">Installation</span>
</div>
</a>
</div>
<!-- Engineering tradeoff notes -->
<div class="docs">
<h3>Engineering notes</h3>
<div class="tradeoffs">
<div class="tradeoff">
<strong>RAF pauses when idle</strong>
<p>requestAnimationFrame loop stops completely on mouseleave. Zero GPU cost when not hovering.</p>
</div>
<div class="tradeoff">
<strong>Semantic fallback</strong>
<p>&lt;img&gt; with alt text always in DOM. Canvas is aria-hidden and purely decorative.</p>
</div>
<div class="tradeoff">
<strong>prefers-reduced-motion</strong>
<p>Canvas hidden via CSS media query. Static image shown instead, no JS branching needed.</p>
</div>
<div class="tradeoff">
<strong>Liquid GLSL shader</strong>
<p>Multi-frequency sine distortion on UV coords, driven by time + hover progress uniform.</p>
</div>
</div>
</div>
</div>
<!-- Three.js from CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
/* ═══════════════════════════════════════════════════════════════
WebGL Hover Component
Pure JS + Three.js + custom GLSL
═══════════════════════════════════════════════════════════════ */
// ── GLSL shaders ──────────────────────────────────────────────
const VERT = /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const FRAG = /* glsl */`
precision highp float;
uniform sampler2D uTexture;
uniform sampler2D uDisp;
uniform float uTime;
uniform float uHover; // 0.0 → 1.0 (eased progress)
uniform vec2 uMouse; // normalised 0..1 in element space
uniform vec2 uRes; // pixel dims of the canvas
uniform vec2 uImgRes; // natural pixel dims of the source image
varying vec2 vUv;
/* Replicate CSS object-fit: cover */
vec2 coverUV(vec2 uv, vec2 canvasRes, vec2 imgRes) {
float canvasAspect = canvasRes.x / canvasRes.y;
float imgAspect = imgRes.x / imgRes.y;
vec2 scale = vec2(1.0);
if (imgAspect > canvasAspect) {
scale.x = canvasAspect / imgAspect;
} else {
scale.y = imgAspect / canvasAspect;
}
return (uv - 0.5) * scale + 0.5;
}
/* Smooth HSL luminance-weighted blend */
vec3 blend(vec3 a, vec3 b, float t) {
return mix(a, b, smoothstep(0.0, 1.0, t));
}
void main() {
/* ── 0. Map UVs to cover-crop space ────────────────────── */
vec2 cuv = coverUV(vUv, uRes, uImgRes);
/* ── 1. Sample displacement map ────────────────────────── */
vec4 disp = texture2D(uDisp, cuv);
/* ── 2. Build multi-layer fluid distortion ─────────────── */
float t = uTime * 0.55;
/* Low-freq base warp */
float warpX = sin(vUv.y * 3.8 + t * 0.9) * 0.012
+ sin(vUv.x * 2.1 - t * 0.6) * 0.008;
float warpY = cos(vUv.x * 4.2 - t * 0.7) * 0.012
+ cos(vUv.y * 2.8 + t * 0.5) * 0.008;
/* Displacement map influence — adds organic detail */
warpX += (disp.r - 0.5) * 0.06;
warpY += (disp.g - 0.5) * 0.06;
/* Ripple from cursor position */
vec2 toMouse = vUv - uMouse;
float mouseDist = length(toMouse * vec2(uRes.x / uRes.y, 1.0));
float ripple = sin(mouseDist * 18.0 - t * 3.0) * 0.018
* exp(-mouseDist * 4.5);
warpX += toMouse.x * ripple;
warpY += toMouse.y * ripple;
/* ── 3. Blend distortion amount with hover progress ─────── */
vec2 distortedUV = cuv + vec2(warpX, warpY) * uHover;
/* ── 4. Sample final texture ────────────────────────────── */
vec4 color = texture2D(uTexture, distortedUV);
/* ── 5. Subtle vignette on hover ────────────────────────── */
float vig = smoothstep(0.85, 0.3, length(vUv - 0.5) * 1.2);
color.rgb = mix(color.rgb, color.rgb * vig, uHover * 0.35);
/* ── 6. Faint luminance boost ───────────────────────────── */
color.rgb += color.rgb * uHover * 0.08;
/* ── 7. Grayscale when not hovering ───────────────────── */
float lum = dot(color.rgb, vec3(0.299, 0.587, 0.114));
color.rgb = mix(vec3(lum), color.rgb, uHover);
gl_FragColor = color;
}
`;
// ── HoverCard class ───────────────────────────────────────────
class HoverCard {
constructor(el) {
this.el = el;
this.hover = 0; // raw hover state 0/1
this.hoverProg = 0; // eased 0..1
this.mouse = { x: 0.5, y: 0.5 };
this.rafId = null;
this.clock = 0;
this.lastTs = null;
this.renderer = null;
this.material = null;
this.canvas = null;
this.disposed = false;
// Respect prefers-reduced-motion at JS level too (belt & suspenders)
this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (this.reducedMotion) return;
this._init();
}
_init() {
const el = this.el;
const img = el.querySelector('.thumb-img');
const imgSrc = el.dataset.img;
const dispSrc = el.dataset.disp;
const w = el.offsetWidth;
const h = img.offsetHeight;
// ── Three.js renderer ──────────────────────────────────
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(w, h);
const canvas = renderer.domElement;
canvas.setAttribute('aria-hidden', 'true');
canvas.setAttribute('role', 'presentation');
// Insert canvas directly after the img (stays inside the anchor)
img.insertAdjacentElement('afterend', canvas);
this.renderer = renderer;
this.canvas = canvas;
// ── Scene ──────────────────────────────────────────────
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0, 1);
camera.position.z = 0.5;
const loader = new THREE.TextureLoader();
loader.crossOrigin = 'anonymous';
let tex, disp;
let loaded = 0;
const onLoad = () => {
loaded++;
if (loaded < 2) return;
tex.minFilter = THREE.LinearFilter;
disp.minFilter = THREE.LinearFilter;
const geo = new THREE.PlaneGeometry(1, 1);
const mat = new THREE.ShaderMaterial({
uniforms: {
uTexture: { value: tex },
uDisp: { value: disp },
uTime: { value: 0 },
uHover: { value: 0 },
uMouse: { value: new THREE.Vector2(0.5, 0.5) },
uRes: { value: new THREE.Vector2(w, h) },
uImgRes: { value: new THREE.Vector2(tex.image.width, tex.image.height) },
},
vertexShader: VERT,
fragmentShader: FRAG,
transparent: true,
});
scene.add(new THREE.Mesh(geo, mat));
this.material = mat;
this.scene = scene;
this.camera = camera;
// Bind events only once textures are ready
this._bindEvents();
};
tex = loader.load(imgSrc, onLoad, undefined, () => onLoad());
disp = loader.load(dispSrc, onLoad, undefined, () => onLoad());
}
_bindEvents() {
const el = this.el;
this._onEnter = (e) => {
this.hover = 1;
this._updateMouse(e);
this._startLoop();
};
this._onMove = (e) => {
this._updateMouse(e);
};
this._onLeave = () => {
this.hover = 0;
// Loop continues until hoverProg decays to ~0, then stops
};
el.addEventListener('mouseenter', this._onEnter);
el.addEventListener('mousemove', this._onMove);
el.addEventListener('mouseleave', this._onLeave);
// Touch support
el.addEventListener('touchstart', this._onEnter, { passive: true });
el.addEventListener('touchend', this._onLeave, { passive: true });
}
_updateMouse(e) {
const rect = this.canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
this.mouse.x = (clientX - rect.left) / rect.width;
this.mouse.y = 1 - (clientY - rect.top) / rect.height; // flip Y for GL
}
_startLoop() {
if (this.rafId !== null) return; // already running
this.lastTs = null;
this._loop();
}
_loop(ts = 0) {
if (this.disposed) return;
if (this.lastTs === null) this.lastTs = ts;
const dt = Math.min((ts - this.lastTs) / 1000, 0.05); // cap delta
this.lastTs = ts;
// Ease hover progress
const target = this.hover;
const speed = target > this.hoverProg ? 3.5 : 2.5; // faster in, slower out
this.hoverProg += (target - this.hoverProg) * speed * dt;
// Advance clock only while something is happening
this.clock += dt;
// Update uniforms
const u = this.material.uniforms;
u.uTime.value = this.clock;
u.uHover.value = this.hoverProg;
u.uMouse.value.set(this.mouse.x, this.mouse.y);
this.renderer.render(this.scene, this.camera);
// Stop the loop when fully at rest (idle, fully decayed)
const atRest = this.hover === 0 && this.hoverProg < 0.002;
if (atRest) {
this.hoverProg = 0;
u.uHover.value = 0;
this.renderer.render(this.scene, this.camera); // final clean frame
this.rafId = null;
this.lastTs = null;
return; // ← RAF stops here
}
this.rafId = requestAnimationFrame((t) => this._loop(t));
}
destroy() {
this.disposed = true;
if (this.rafId) cancelAnimationFrame(this.rafId);
const el = this.el;
if (this._onEnter) {
el.removeEventListener('mouseenter', this._onEnter);
el.removeEventListener('mousemove', this._onMove);
el.removeEventListener('mouseleave', this._onLeave);
}
if (this.renderer) this.renderer.dispose();
if (this.canvas && this.canvas.parentNode) this.canvas.remove();
}
}
// ── Init all cards ────────────────────────────────────────────
const cards = [];
document.querySelectorAll('.project-card').forEach(el => {
cards.push(new HoverCard(el));
});
// Cleanup on unload (good practice for SPAs)
window.addEventListener('beforeunload', () => {
cards.forEach(c => c.destroy());
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment