Created
October 3, 2025 05:24
-
-
Save Xaypanya/2477a657709ed5c783d52a894e5f74b0 to your computer and use it in GitHub Desktop.
Interactive Particle Image Hover Effect
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>Hover → Particle</title> | |
| <style> | |
| :root { | |
| --w: 520px; /* canvas width (tweakable) */ | |
| --density: 6; /* smaller = more particles */ | |
| --particle-size: 3; /* pixel size of each particle */ | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { | |
| height: 100%; | |
| margin: 0; | |
| background: #0b0f14; | |
| color: #cbd5e1; | |
| font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji; | |
| display: grid; | |
| place-items: center; | |
| } | |
| .card { | |
| width: min(var(--w), 90vw); | |
| background: #0f172a; | |
| border: 1px solid #1f2937; | |
| border-radius: 16px; | |
| padding: 16px; | |
| box-shadow: 0 10px 30px rgba(0,0,0,.25); | |
| } | |
| .stage { | |
| position: relative; | |
| overflow: hidden; | |
| border-radius: 12px; | |
| background: radial-gradient(1200px 500px at 40% -10%, rgba(59,130,246,.12), transparent 60%), | |
| radial-gradient(800px 400px at 80% 110%, rgba(16,185,129,.12), transparent 60%); | |
| } | |
| canvas { display: block; width: 100%; height: auto; } | |
| .hint { | |
| text-align: center; | |
| font-size: 12px; | |
| opacity: .7; | |
| margin-top: 10px; | |
| } | |
| .badge { | |
| position: absolute; inset: 10px auto auto 10px; | |
| font-size: 11px; letter-spacing: .06em; | |
| padding: 6px 8px; border-radius: 999px; | |
| background: rgba(17,24,39,.7); border: 1px solid rgba(255,255,255,.08); | |
| backdrop-filter: blur(6px); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <div class="stage"> | |
| <span class="badge">hover to disperse • particles reform as you move</span> | |
| <canvas id="c"></canvas> | |
| </div> | |
| <div class="hint">Image: fluffy cat (PNG). You can swap the URL inside the script.</div> | |
| </div> | |
| <script> | |
| // === CONFIG === | |
| const IMG_URL = "https://static.vecteezy.com/system/resources/thumbnails/047/493/988/small_2x/hairy-fluffy-cat-playing-png.png"; | |
| const DENSITY = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--density')) || 6; | |
| const PARTICLE_SIZE = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--particle-size')) || 3; | |
| const MAX_PARTICLES = 20000; | |
| const EXPLOSION_SPEED = 1.5; | |
| const RETURN_SPEED = 0.10; | |
| const JITTER = 0.4; | |
| const ALPHA_THRESHOLD = 40; | |
| const INFLUENCE_RADIUS = 80; | |
| const canvas = document.getElementById('c'); | |
| const ctx = canvas.getContext('2d'); | |
| const sampler = document.createElement('canvas'); | |
| const sctx = sampler.getContext('2d'); | |
| let w, h, device = Math.min(window.devicePixelRatio || 1, 2); | |
| let particles = []; | |
| let mouse = null; // start with no mouse | |
| function resizeTo(widthPx) { | |
| w = Math.floor(widthPx); | |
| h = Math.floor(widthPx * img.naturalHeight / img.naturalWidth); | |
| canvas.width = Math.floor(w * device); | |
| canvas.height = Math.floor(h * device); | |
| sampler.width = Math.floor(w); | |
| sampler.height = Math.floor(h); | |
| canvas.style.width = w + 'px'; | |
| canvas.style.height = h + 'px'; | |
| } | |
| class P { | |
| constructor(x, y, color) { | |
| this.x = x; this.y = y; | |
| this.px = x; this.py = y; | |
| this.vx = 0; this.vy = 0; | |
| this.c = color; | |
| } | |
| draw() { | |
| ctx.fillStyle = this.c; | |
| ctx.fillRect( | |
| Math.floor(this.px * device), | |
| Math.floor(this.py * device), | |
| PARTICLE_SIZE * device, | |
| PARTICLE_SIZE * device | |
| ); | |
| } | |
| } | |
| function buildParticles() { | |
| particles = []; | |
| sctx.clearRect(0, 0, sampler.width, sampler.height); | |
| sctx.drawImage(img, 0, 0, sampler.width, sampler.height); | |
| let imgData; | |
| try { | |
| imgData = sctx.getImageData(0, 0, sampler.width, sampler.height).data; | |
| } catch (e) { | |
| console.warn('CORS blocked pixel sampling.', e); | |
| } | |
| const step = Math.max(1, DENSITY); | |
| const w0 = sampler.width; | |
| const h0 = sampler.height; | |
| for (let y = 0; y < h0; y += step) { | |
| for (let x = 0; x < w0; x += step) { | |
| if (particles.length >= MAX_PARTICLES) break; | |
| let col = '#ffffff'; | |
| if (imgData) { | |
| const idx = (y * w0 + x) * 4; | |
| const r = imgData[idx], g = imgData[idx + 1], b = imgData[idx + 2], a = imgData[idx + 3]; | |
| if (a < ALPHA_THRESHOLD) continue; | |
| col = `rgb(${r},${g},${b})`; | |
| } else { | |
| if (((x + y) % (step * 2)) !== 0) continue; | |
| } | |
| particles.push(new P(x, y, col)); | |
| } | |
| } | |
| } | |
| function renderIdle() { | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.save(); | |
| ctx.scale(device, device); | |
| ctx.drawImage(img, 0, 0, w, h); | |
| ctx.restore(); | |
| } | |
| function renderParticles() { | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.save(); | |
| for (const p of particles) p.draw(); | |
| ctx.restore(); | |
| } | |
| let raf; | |
| function tick() { | |
| for (const p of particles) { | |
| if (mouse) { | |
| const dx = p.px - mouse.x; | |
| const dy = p.py - mouse.y; | |
| const d = Math.hypot(dx, dy) + 0.001; | |
| if (d < INFLUENCE_RADIUS) { | |
| const repel = (INFLUENCE_RADIUS - d) / INFLUENCE_RADIUS; | |
| p.vx += (dx / d) * repel * 1.5 + (Math.random() - .5) * JITTER; | |
| p.vy += (dy / d) * repel * 1.5 + (Math.random() - .5) * JITTER; | |
| } else { | |
| p.vx += (p.x - p.px) * RETURN_SPEED; | |
| p.vy += (p.y - p.py) * RETURN_SPEED; | |
| } | |
| } else { | |
| // no mouse → particles go back to home position and stay | |
| p.vx += (p.x - p.px) * RETURN_SPEED; | |
| p.vy += (p.y - p.py) * RETURN_SPEED; | |
| } | |
| p.vx *= 0.9; p.vy *= 0.9; | |
| p.px += p.vx; p.py += p.vy; | |
| } | |
| renderParticles(); | |
| raf = requestAnimationFrame(tick); | |
| } | |
| function onMove(e) { | |
| const rect = canvas.getBoundingClientRect(); | |
| mouse = { | |
| x: (e.clientX - rect.left) * (w / rect.width), | |
| y: (e.clientY - rect.top) * (h / rect.height) | |
| }; | |
| } | |
| function onLeave() { | |
| mouse = null; // stop influencing when mouse leaves | |
| } | |
| const img = new Image(); | |
| img.crossOrigin = 'anonymous'; | |
| img.onload = () => { | |
| const targetW = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--w')) || 520; | |
| resizeTo(targetW); | |
| buildParticles(); | |
| renderIdle(); | |
| cancelAnimationFrame(raf); | |
| raf = requestAnimationFrame(tick); | |
| }; | |
| img.src = IMG_URL; | |
| let t; | |
| window.addEventListener('resize', () => { | |
| clearTimeout(t); | |
| t = setTimeout(() => { | |
| const rect = canvas.parentElement.getBoundingClientRect(); | |
| const targetW = Math.min(rect.width, 900); | |
| resizeTo(targetW); | |
| buildParticles(); | |
| renderIdle(); | |
| }, 120); | |
| }); | |
| canvas.addEventListener('mousemove', onMove); | |
| canvas.addEventListener('mouseleave', onLeave); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment