(() => { "use strict"; // ---------- utils ---------- const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); function attr(el, name, fallback = null) { const v = el.getAttribute(name); return v === null ? fallback : v; } function parseNumber(v, fallback) { const n = parseFloat(v); return Number.isFinite(n) ? n : fallback; } function parseBool(v, fallback) { if (v === null || v === undefined) return fallback; const s = String(v).trim().toLowerCase(); if (s === "" || s === "1" || s === "true" || s === "yes") return true; if (s === "0" || s === "false" || s === "no") return false; return fallback; } function prefersReducedMotion() { return !!( window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches ); } // ---------- engine ---------- const instances = new Map(); // hostEl -> instance let rafId = 0; let lastNow = performance.now(); const io = "IntersectionObserver" in window ? new IntersectionObserver( (entries) => { for (const entry of entries) { const host = entry.target; const inst = instances.get(host); if (inst) inst.visible = !!entry.isIntersecting; } }, { threshold: 0.01 } ) : null; function startLoop() { if (rafId) return; lastNow = performance.now(); rafId = requestAnimationFrame(loop); } function stopLoopIfEmpty() { if (instances.size === 0 && rafId) { cancelAnimationFrame(rafId); rafId = 0; } } function loop(now) { const dt = clamp((now - lastNow) / 1000, 0, 0.05); // seconds lastNow = now; for (const inst of instances.values()) { if (!inst.visible) continue; if (!inst.canvas.isConnected || !inst.host.isConnected) continue; if (inst.w <= 0 || inst.h <= 0) continue; inst.step(dt); inst.draw(); } rafId = requestAnimationFrame(loop); } class BlueDotsInstance { constructor(host) { this.host = host; // Canvas this.canvas = document.createElement("canvas"); this.canvas.setAttribute("data-blue-dots-canvas", "1"); this.canvas.setAttribute("aria-hidden", "true"); this.ctx = this.canvas.getContext("2d", { alpha: true }); // ---------- options ---------- this.count = Math.max( 0, Math.floor(parseNumber(attr(host, "data-blue-dots-count", "20"), 20)) ); this.radius = Math.max( 0.1, parseNumber(attr(host, "data-blue-dots-size", "7"), 7) ); // radius in px; r=7 => ~14px diameter this.color = ( attr(host, "data-blue-dots-color", "#2E23FF") || "#2E23FF" ).trim(); // Home drift speed (px/s) this.minSpeed = Math.max( 0, parseNumber(attr(host, "data-blue-dots-min-speed", "8"), 8) ); this.maxSpeed = Math.max( this.minSpeed, parseNumber(attr(host, "data-blue-dots-max-speed", "16"), 16) ); this.useDpr = parseBool(attr(host, "data-blue-dots-dpr", "true"), true); // Interaction (mouse) this.repelRadius = Math.max( 0, parseNumber(attr(host, "data-blue-dots-repel-radius", "140"), 140) ); this.repelStrength = Math.max( 0, parseNumber(attr(host, "data-blue-dots-repel-strength", "950"), 950) ); // Spring back to home this.spring = Math.max( 0, parseNumber(attr(host, "data-blue-dots-spring", "10"), 10) ); // typical 6..18 // Motion constraints this.damping = clamp( parseNumber(attr(host, "data-blue-dots-damping", "0.90"), 0.9), 0.75, 0.995 ); this.maxVel = Math.max( 20, parseNumber(attr(host, "data-blue-dots-max-vel", "320"), 320) ); // Mouse wind this.windStrength = Math.max( 0, parseNumber(attr(host, "data-blue-dots-wind", "0.55"), 0.55) ); this.windMax = Math.max( 0, parseNumber(attr(host, "data-blue-dots-wind-max", "260"), 260) ); this.windFalloff = Math.max( 0.1, parseNumber(attr(host, "data-blue-dots-wind-falloff", "1.0"), 1.0) ); // Faster settle after mouse pass this.afterglowMs = Math.max( 0, parseNumber(attr(host, "data-blue-dots-afterglow", "280"), 280) ); this.afterglowBoost = Math.max( 0, parseNumber(attr(host, "data-blue-dots-afterglow-boost", "2.4"), 2.4) ); // Bounds mode const mode = (attr(host, "data-blue-dots-bounds", "bounce") || "bounce") .trim() .toLowerCase(); this.boundsMode = mode === "wrap" ? "wrap" : "bounce"; // default bounce this.bounceLoss = clamp( parseNumber(attr(host, "data-blue-dots-bounce-loss", "0.96"), 0.96), 0.7, 1 ); // Collisions this.enableCollisions = parseBool( attr(host, "data-blue-dots-collide", "true"), true ); this.restitution = clamp( parseNumber(attr(host, "data-blue-dots-restitution", "0.95"), 0.95), 0, 1 ); this.collisionIterations = Math.max( 1, Math.floor( parseNumber(attr(host, "data-blue-dots-collide-iters", "1"), 1) ) ); // ---------- state ---------- this.visible = true; this.w = 0; this.h = 0; this.dpr = 1; // Mouse state (local coords) this.mouse = { x: 0, y: 0, active: false, vx: 0, vy: 0, lastX: 0, lastY: 0, lastT: performance.now(), lastActiveT: 0, }; // Dots (each has home position hx,hy + drift hvx,hvy) this.dots = []; this.prepareHostStyles(); this.prepareCanvasStyles(); host.insertBefore(this.canvas, host.firstChild); // Resize this.ro = new ResizeObserver(() => this.resize()); this.ro.observe(host); // Visibility if (io) io.observe(host); // Pointer this.onPointerMove = (e) => this.handlePointerMove(e); this.onPointerEnter = () => this.handlePointerEnter(); this.onPointerLeave = () => this.handlePointerLeave(); host.addEventListener("pointermove", this.onPointerMove, { passive: true, }); host.addEventListener("pointerenter", this.onPointerEnter, { passive: true, }); host.addEventListener("pointerleave", this.onPointerLeave, { passive: true, }); // Init this.resize(); } // ---------- setup ---------- prepareHostStyles() { const cs = getComputedStyle(this.host); if (cs.position === "static") this.host.style.position = "relative"; if (cs.isolation === "auto") this.host.style.isolation = "isolate"; if (cs.zIndex === "auto") this.host.style.zIndex = "0"; } prepareCanvasStyles() { const s = this.canvas.style; s.position = "absolute"; s.inset = "0"; s.width = "100%"; s.height = "100%"; s.pointerEvents = "none"; s.zIndex = "-1"; s.display = "block"; } resize() { if (!this.ctx) return; const rect = this.host.getBoundingClientRect(); this.w = Math.max(1, rect.width); this.h = Math.max(1, rect.height); this.dpr = this.useDpr ? clamp(window.devicePixelRatio || 1, 1, 2.5) : 1; this.canvas.width = Math.round(this.w * this.dpr); this.canvas.height = Math.round(this.h * this.dpr); this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); this.resetDots(); } resetDots() { const n = this.count; this.dots = new Array(n); for (let i = 0; i < n; i++) { const hx = Math.random() * this.w; const hy = Math.random() * this.h; const angle = Math.random() * Math.PI * 2; const sp = this.minSpeed + Math.random() * (this.maxSpeed - this.minSpeed); const hvx = Math.cos(angle) * sp; const hvy = Math.sin(angle) * sp; this.dots[i] = { // actual position x: hx, y: hy, vx: 0, vy: 0, // home target hx, hy, // home drift hvx, hvy, }; } } // ---------- pointer ---------- handlePointerEnter() { const m = this.mouse; m.active = true; m.lastActiveT = performance.now(); } handlePointerLeave() { const m = this.mouse; m.active = false; m.lastActiveT = performance.now(); } handlePointerMove(e) { const rect = this.host.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const m = this.mouse; const now = performance.now(); const dt = Math.max(0.001, (now - m.lastT) / 1000); const vx = (mx - m.lastX) / dt; const vy = (my - m.lastY) / dt; // Smooth mouse velocity (reduces spikes) const smoothing = 0.25; m.vx = m.vx + (vx - m.vx) * smoothing; m.vy = m.vy + (vy - m.vy) * smoothing; m.x = mx; m.y = my; m.lastX = mx; m.lastY = my; m.lastT = now; m.active = true; m.lastActiveT = now; } // ---------- bounds ---------- applyBoundsToHome(p) { const pad = this.radius + 1; if (this.boundsMode === "wrap") { if (p.hx < -pad) p.hx = this.w + pad; if (p.hx > this.w + pad) p.hx = -pad; if (p.hy < -pad) p.hy = this.h + pad; if (p.hy > this.h + pad) p.hy = -pad; return; } const loss = this.bounceLoss; if (p.hx < pad) { p.hx = pad; if (p.hvx < 0) p.hvx = -p.hvx * loss; } else if (p.hx > this.w - pad) { p.hx = this.w - pad; if (p.hvx > 0) p.hvx = -p.hvx * loss; } if (p.hy < pad) { p.hy = pad; if (p.hvy < 0) p.hvy = -p.hvy * loss; } else if (p.hy > this.h - pad) { p.hy = this.h - pad; if (p.hvy > 0) p.hvy = -p.hvy * loss; } } applyBoundsToDot(p) { const pad = this.radius + 1; if (this.boundsMode === "wrap") { if (p.x < -pad) p.x = this.w + pad; if (p.x > this.w + pad) p.x = -pad; if (p.y < -pad) p.y = this.h + pad; if (p.y > this.h + pad) p.y = -pad; return; } const loss = this.bounceLoss; if (p.x < pad) { p.x = pad; if (p.vx < 0) p.vx = -p.vx * loss; } else if (p.x > this.w - pad) { p.x = this.w - pad; if (p.vx > 0) p.vx = -p.vx * loss; } if (p.y < pad) { p.y = pad; if (p.vy < 0) p.vy = -p.vy * loss; } else if (p.y > this.h - pad) { p.y = this.h - pad; if (p.vy > 0) p.vy = -p.vy * loss; } } // ---------- collisions ---------- resolveCollisions() { const dots = this.dots; const r = this.radius; const minDist = r * 2; const minDist2 = minDist * minDist; const e = this.restitution; for (let i = 0; i < dots.length; i++) { const a = dots[i]; for (let j = i + 1; j < dots.length; j++) { const b = dots[j]; let dx = b.x - a.x; let dy = b.y - a.y; let d2 = dx * dx + dy * dy; if (d2 >= minDist2) continue; if (d2 < 0.000001) { dx = (Math.random() - 0.5) * 0.01; dy = (Math.random() - 0.5) * 0.01; d2 = dx * dx + dy * dy; } const d = Math.sqrt(d2); const nx = dx / d; const ny = dy / d; // Positional correction (separate overlap) const overlap = minDist - d; const sep = overlap * 0.5; a.x -= nx * sep; a.y -= ny * sep; b.x += nx * sep; b.y += ny * sep; // Velocity impulse (equal mass) const rvx = b.vx - a.vx; const rvy = b.vy - a.vy; const vn = rvx * nx + rvy * ny; // Already separating if (vn > 0) continue; // j = -(1+e)*vn/2 (equal mass) const jImp = -(1 + e) * vn * 0.5; const ix = jImp * nx; const iy = jImp * ny; a.vx -= ix; a.vy -= iy; b.vx += ix; b.vy += iy; // Keep inside bounds after separation this.applyBoundsToDot(a); this.applyBoundsToDot(b); } } } // ---------- physics ---------- step(dt) { const m = this.mouse; const damping = Math.pow(this.damping, dt * 60); // Afterglow: boost spring return shortly after mouse activity ends const nowT = performance.now(); const sinceActive = nowT - (m.lastActiveT || 0); const ag = this.afterglowMs > 0 && sinceActive >= 0 && sinceActive <= this.afterglowMs ? 1 + (this.afterglowBoost - 1) * (1 - sinceActive / this.afterglowMs) : 1; const springK = this.spring * ag; // Mouse wind speed clamp let mvx = m.vx; let mvy = m.vy; const ms = Math.sqrt(mvx * mvx + mvy * mvy); if (ms > this.windMax && ms > 0.0001) { const inv = this.windMax / ms; mvx *= inv; mvy *= inv; } const rr = this.repelRadius; const rr2 = rr * rr; // 1) advance dots for (let i = 0; i < this.dots.length; i++) { const p = this.dots[i]; // Move HOME slowly (drift) p.hx += p.hvx * dt; p.hy += p.hvy * dt; this.applyBoundsToHome(p); // Spring to HOME (return-to-origin behavior) p.vx += (p.hx - p.x) * springK * dt; p.vy += (p.hy - p.y) * springK * dt; // Mouse repel + wind (does NOT move home) const dx = p.x - m.x; const dy = p.y - m.y; const d2 = dx * dx + dy * dy; if ( (m.active || sinceActive < this.afterglowMs) && d2 > 0.0001 && d2 < rr2 ) { const d = Math.sqrt(d2); const nx = dx / d; const ny = dy / d; const t = 1 - d / rr; // 0..1 const fall = t * t; // Radial repel const force = this.repelStrength * fall; p.vx += nx * force * dt; p.vy += ny * force * dt; // Wind along mouse direction const windFall = Math.pow(t, this.windFalloff); const wind = this.windStrength * windFall; p.vx += mvx * wind * dt; p.vy += mvy * wind * dt; } // Damping p.vx *= damping; p.vy *= damping; // Clamp velocity const v2 = p.vx * p.vx + p.vy * p.vy; const mv = this.maxVel; if (v2 > mv * mv) { const inv = mv / Math.sqrt(v2); p.vx *= inv; p.vy *= inv; } // Integrate p.x += p.vx * dt; p.y += p.vy * dt; // Dot bounds this.applyBoundsToDot(p); } // 2) collisions post-pass (can iterate) if (this.enableCollisions && this.dots.length > 1) { for (let k = 0; k < this.collisionIterations; k++) { this.resolveCollisions(); } } } // ---------- render ---------- draw() { const ctx = this.ctx; if (!ctx) return; ctx.clearRect(0, 0, this.w, this.h); ctx.fillStyle = this.color; for (let i = 0; i < this.dots.length; i++) { const p = this.dots[i]; ctx.beginPath(); ctx.arc(p.x, p.y, this.radius, 0, Math.PI * 2); ctx.fill(); } } // ---------- cleanup ---------- destroy() { try { if (this.ro) this.ro.disconnect(); } catch (e) {} try { if (io) io.unobserve(this.host); } catch (e) {} try { this.host.removeEventListener("pointermove", this.onPointerMove); this.host.removeEventListener("pointerenter", this.onPointerEnter); this.host.removeEventListener("pointerleave", this.onPointerLeave); } catch (e) {} if (this.canvas && this.canvas.parentNode) { this.canvas.parentNode.removeChild(this.canvas); } } } function initHost(host) { if (!host || instances.has(host)) return; if (prefersReducedMotion()) return; const inst = new BlueDotsInstance(host); instances.set(host, inst); startLoop(); } function initAll(root = document) { const nodes = root.querySelectorAll("[data-blue-dots-bg]"); nodes.forEach(initHost); } function cleanupRemoved() { for (const [host, inst] of instances.entries()) { if (!host.isConnected) { inst.destroy(); instances.delete(host); } } stopLoopIfEmpty(); } // Webflow/dynamic DOM support const mo = new MutationObserver((muts) => { for (const m of muts) { for (const n of m.addedNodes) { if (!(n instanceof Element)) continue; if (n.matches && n.matches("[data-blue-dots-bg]")) initHost(n); if (n.querySelectorAll) initAll(n); } } cleanupRemoved(); }); function boot() { initAll(document); mo.observe(document.documentElement, { childList: true, subtree: true }); } // Optional public API window.BlueDotsBG = { initAll, init: initHost, refresh() { for (const inst of instances.values()) inst.resize(); }, }; // Webflow-friendly ready if (window.Webflow && Array.isArray(window.Webflow)) { window.Webflow.push(boot); } else if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot); } else { boot(); } })();