Created
February 18, 2026 22:18
-
-
Save merttoka/848821ab957bedf8354334b2af01f88d to your computer and use it in GitHub Desktop.
Fluid displacement image hover
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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 <img> 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><img> 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