(() => { "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 parseStarsColor(el) { const mode = (attr(el, "data-stars-color", "light") || "light") .trim() .toLowerCase(); // Only two modes: if (mode === "dark") return { r: 15, g: 23, b: 42 }; // dark particles return { r: 255, g: 255, b: 255 }; // light particles (default) } 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 dtFrames = clamp((now - lastNow) / 16.6667, 0, 2); // normalized to ~60fps frames 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.draw(dtFrames); } rafId = requestAnimationFrame(loop); } class StarsInstance { constructor(host) { this.host = host; this.canvas = document.createElement("canvas"); this.canvas.setAttribute("data-stars-canvas", "1"); this.canvas.setAttribute("aria-hidden", "true"); this.ctx = this.canvas.getContext("2d"); this.stars = []; this.visible = true; // Options (per element via attributes) this.starDensity = parseNumber( attr(host, "data-stars-density", "0.00025"), 0.00025 ); this.allStarsTwinkle = parseBool( attr(host, "data-stars-all-twinkle", "true"), true ); this.twinkleProbability = parseNumber( attr(host, "data-stars-twinkle-prob", "0.7"), 0.7 ); this.minTwinkleSpeed = parseNumber( attr(host, "data-stars-min-twinkle", "0.5"), 0.5 ); this.maxTwinkleSpeed = parseNumber( attr(host, "data-stars-max-twinkle", "1"), 1 ); this.minSpeed = parseNumber( attr(host, "data-stars-min-speed", "0.01"), 0.01 ); this.maxSpeed = parseNumber( attr(host, "data-stars-max-speed", "0.2"), 0.2 ); this.radiusMin = parseNumber(attr(host, "data-stars-radius-min", "1"), 1); this.radiusMax = parseNumber( attr(host, "data-stars-radius-max", "1.2"), 1.2 ); this.opacityMul = parseNumber(attr(host, "data-stars-opacity", "1"), 1); this.useDpr = parseBool(attr(host, "data-stars-dpr", "true"), true); // State this.w = 0; this.h = 0; this.dpr = 1; this.color = parseStarsColor(host); this.prepareHostStyles(); this.prepareCanvasStyles(); // Insert canvas behind content host.insertBefore(this.canvas, host.firstChild); // Resize handling this.ro = new ResizeObserver(() => this.resize()); this.ro.observe(host); // Visibility handling if (io) io.observe(host); // Init this.resize(); } prepareHostStyles() { const cs = getComputedStyle(this.host); // Ensure absolute canvas positions relative to the host if (cs.position === "static") this.host.style.position = "relative"; // Prevent negative z-index canvas from escaping behind other page elements 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); // Draw in CSS pixels this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); // Update color (in case you changed attributes dynamically) this.color = parseStarsColor(this.host); // Recreate particles on resize this.stars = this.createStars(this.w, this.h); } createStars(w, h) { const count = Math.floor(w * h * this.starDensity); const stars = new Array(count); for (let k = 0; k < count; k++) { const speed = this.minSpeed + Math.random() * (this.maxSpeed - this.minSpeed); const angle = Math.random() * Math.PI * 2; const twinkleSpeed = this.allStarsTwinkle || Math.random() < this.twinkleProbability ? this.minTwinkleSpeed + Math.random() * (this.maxTwinkleSpeed - this.minTwinkleSpeed) : null; const radius = (this.radiusMax - this.radiusMin) * Math.random() + this.radiusMin; stars[k] = { x: Math.random() * w, y: Math.random() * h, radius, baseOpacity: (0.5 * Math.random() + 0.5) * this.opacityMul, twinkleSpeed, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, }; } return stars; } draw(dtFrames) { const ctx = this.ctx; if (!ctx) return; ctx.clearRect(0, 0, this.w, this.h); const { r, g, b } = this.color; const now = Date.now(); for (let i = 0; i < this.stars.length; i++) { const s = this.stars[i]; let x = s.x + s.vx * dtFrames; let y = s.y + s.vy * dtFrames; // Wrap around edges if (x < 0) x = this.w; if (x > this.w) x = 0; if (y < 0) y = this.h; if (y > this.h) y = 0; const opacity = s.twinkleSpeed !== null ? (0.5 + Math.abs(0.5 * Math.sin((0.001 * now) / s.twinkleSpeed))) * this.opacityMul : s.baseOpacity; ctx.beginPath(); ctx.arc(x, y, s.radius, 0, Math.PI * 2); ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`; ctx.fill(); s.x = x; s.y = y; } } destroy() { try { if (this.ro) this.ro.disconnect(); } catch (e) {} try { if (io) io.unobserve(this.host); } 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 StarsInstance(host); instances.set(host, inst); startLoop(); } function initAll(root = document) { const nodes = root.querySelectorAll("[data-stars-bg]"); nodes.forEach(initHost); } function cleanupRemoved() { for (const [host, inst] of instances.entries()) { if (!host.isConnected) { inst.destroy(); instances.delete(host); } } stopLoopIfEmpty(); } // Auto-init and watch DOM changes (Webflow interactions, CMS, dynamic content) 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-stars-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.StarsBG = { 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(); } })();