From 4cf5281343c4dc97cb13daf479051481471cc843 Mon Sep 17 00:00:00 2001 From: DANIEL KATOTO Date: Mon, 9 Mar 2026 19:10:12 +0300 Subject: [PATCH] Refactor tilt animation engine for performance and maintainability Signed-off-by: DANIEL KATOTO --- static/scripts/hero-glass.js | 265 +++++++++++++++++++++++------------ 1 file changed, 172 insertions(+), 93 deletions(-) diff --git a/static/scripts/hero-glass.js b/static/scripts/hero-glass.js index 722a7ab9..901cb5f2 100644 --- a/static/scripts/hero-glass.js +++ b/static/scripts/hero-glass.js @@ -1,99 +1,178 @@ -const root = document.documentElement; -const hero = document.querySelector("#hero"); -const tiltTargets = document.querySelectorAll("[data-tilt]"); -const floaters = document.querySelectorAll("[data-float]"); - -let frame; -let heroInView = true; -let heroRect = null; -let heroRectDirty = true; -const pointer = { - x: window.innerWidth / 2, - y: window.innerHeight / 2, -}; - -const scheduleUpdateScene = () => { - if (!frame) { - frame = requestAnimationFrame(updateScene); - } -}; - -const refreshHeroRect = () => { - if (!hero || !heroInView) return; - heroRect = hero.getBoundingClientRect(); - heroRectDirty = false; -}; - -const updateScene = () => { - root.style.setProperty("--cursor-x", `${pointer.x}px`); - root.style.setProperty("--cursor-y", `${pointer.y}px`); - - if (hero && heroInView) { - if (heroRectDirty || !heroRect) { - refreshHeroRect(); +(() => { + "use strict"; + + const root = document.documentElement; + const hero = document.querySelector("#hero"); + const tiltTargets = document.querySelectorAll("[data-tilt]"); + const floaters = document.querySelectorAll("[data-float]"); + + const state = { + pointer: { + x: window.innerWidth / 2, + y: window.innerHeight / 2 + }, + visibleTargets: new Set(), + rectCache: new Map(), + frame: null, + isDirty: true + }; + + /** + * Intersection Observer + * Tracks elements that are visible. + */ + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + + if (entry.isIntersecting) { + state.visibleTargets.add(entry.target); + state.isDirty = true; + } else { + state.visibleTargets.delete(entry.target); + state.rectCache.delete(entry.target); + + entry.target.style.setProperty("--tilt-x", "0deg"); + entry.target.style.setProperty("--tilt-y", "0deg"); + } + + }); + }, { threshold: 0.1 }); + + if (hero) observer.observe(hero); + tiltTargets.forEach(el => observer.observe(el)); + + /** + * Resize Observer + * Marks layout as dirty only when elements actually resize. + */ + const resizeObserver = new ResizeObserver(() => { + state.isDirty = true; + }); + + if (hero) resizeObserver.observe(hero); + tiltTargets.forEach(el => resizeObserver.observe(el)); + + /** + * GPU hint for smoother animations + */ + tiltTargets.forEach(el => { + el.style.willChange = "transform"; + }); + + /** + * Measurement Phase (Read) + */ + const refreshRects = () => { + state.visibleTargets.forEach(el => { + state.rectCache.set(el, el.getBoundingClientRect()); + }); + + state.isDirty = false; + }; + + /** + * Render Phase (Write) + */ + const updateScene = () => { + root.style.setProperty("--cursor-x", `${state.pointer.x}px`); + root.style.setProperty("--cursor-y", `${state.pointer.y}px`); + + if (state.visibleTargets.size === 0) { + state.frame = null; + return; } - const relX = (pointer.x - heroRect.left) / heroRect.width - 0.5; - const relY = (pointer.y - heroRect.top) / heroRect.height - 0.5; - hero.style.setProperty("--tilt-x", `${(-relY * 7).toFixed(2)}deg`); - hero.style.setProperty("--tilt-y", `${(relX * 9).toFixed(2)}deg`); - } - - tiltTargets.forEach((target) => { - const rect = target.getBoundingClientRect(); - const relX = (pointer.x - rect.left) / rect.width - 0.5; - const relY = (pointer.y - rect.top) / rect.height - 0.5; - target.style.setProperty("--tilt-x", `${(-relY * 6).toFixed(2)}deg`); - target.style.setProperty("--tilt-y", `${(relX * 6).toFixed(2)}deg`); + + if (state.isDirty) refreshRects(); + + state.visibleTargets.forEach((el) => { + + const rect = state.rectCache.get(el); + if (!rect) return; + + const relX = (state.pointer.x - rect.left) / rect.width - 0.5; + const relY = (state.pointer.y - rect.top) / rect.height - 0.5; + + const intensity = el === hero ? 10 : 6; + + const tiltX = Math.round(-relY * intensity * 100) / 100; + const tiltY = Math.round(relX * intensity * 100) / 100; + + el.style.setProperty("--tilt-x", `${tiltX}deg`); + el.style.setProperty("--tilt-y", `${tiltY}deg`); + }); + + state.frame = null; + }; + + const scheduleUpdate = () => { + if (!state.frame) { + state.frame = requestAnimationFrame(updateScene); + } + }; + + /** + * Pointer Movement + */ + window.addEventListener("pointermove", (e) => { + + if ( + e.clientX === state.pointer.x && + e.clientY === state.pointer.y + ) return; + + state.pointer.x = e.clientX; + state.pointer.y = e.clientY; + + scheduleUpdate(); + + }, { passive: true }); + + /** + * Touch / pointer down responsiveness + */ + window.addEventListener("pointerdown", (e) => { + state.pointer.x = e.clientX; + state.pointer.y = e.clientY; + scheduleUpdate(); + }, { passive: true }); + + /** + * Reset pointer when leaving window + */ + window.addEventListener("pointerleave", () => { + state.pointer.x = window.innerWidth / 2; + state.pointer.y = window.innerHeight / 2; + scheduleUpdate(); }); - frame = null; -}; - -const handlePointer = (event) => { - pointer.x = event.clientX; - pointer.y = event.clientY; - scheduleUpdateScene(); -}; - -window.addEventListener("pointermove", handlePointer, { passive: true }); -window.addEventListener("pointerdown", handlePointer, { passive: true }); -window.addEventListener("pointerleave", () => { - pointer.x = window.innerWidth / 2; - pointer.y = window.innerHeight / 2; - scheduleUpdateScene(); -}); -window.addEventListener("resize", () => { - pointer.x = window.innerWidth / 2; - pointer.y = window.innerHeight / 2; - heroRectDirty = true; - scheduleUpdateScene(); -}, { passive: true }); -window.addEventListener("scroll", () => { - heroRectDirty = true; -}, { passive: true }); - -if (hero) { - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - heroInView = entry.isIntersecting; - if (!heroInView) { - hero.style.setProperty("--tilt-x", "0deg"); - hero.style.setProperty("--tilt-y", "0deg"); - heroRect = null; - } else { - heroRectDirty = true; - scheduleUpdateScene(); - } + /** + * Scroll Throttling + */ + let scrollTick = false; + + window.addEventListener("scroll", () => { + + if (!scrollTick) { + scrollTick = true; + + requestAnimationFrame(() => { + state.isDirty = true; + scrollTick = false; }); - }, - { threshold: 0.2 }, - ); - observer.observe(hero); -} + } + + }, { passive: true }); + + /** + * Floater Animation Delays + */ + floaters.forEach((item, index) => { + item.style.animationDelay = `${index * -0.5}s`; + }); -floaters.forEach((item, index) => { - item.style.animationDelay = `${index * -2.5}s`; -}); + /** + * Initial Run + */ + updateScene(); -updateScene(); +})(); \ No newline at end of file