From edb47668d3c4e41a8899c1e34fa8a35212fb91dd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Jan 2026 09:15:16 +0000 Subject: [PATCH 1/4] Add parallax text observer Co-authored-by: goodslyfox --- guest-book.html | 1 + js/parallax-text.js | 162 ++++++++++++++++++++++++++++++++++++++ styles/general-styles.css | 4 + 3 files changed, 167 insertions(+) create mode 100644 js/parallax-text.js diff --git a/guest-book.html b/guest-book.html index edd4294..c38a6d3 100644 --- a/guest-book.html +++ b/guest-book.html @@ -27,6 +27,7 @@ + \ No newline at end of file diff --git a/js/parallax-text.js b/js/parallax-text.js new file mode 100644 index 0000000..2861a7c --- /dev/null +++ b/js/parallax-text.js @@ -0,0 +1,162 @@ +(() => { + const CLASS_NAME = 'parallax-text'; + const DEFAULT_SPEED = 1; + const DEFAULT_DISTANCE = 1130; + const activeElements = new Set(); + const observedElements = new Set(); + let rafId = null; + + const prefersReducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ); + + const getNumber = (value, fallback) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; + }; + + const getSpeed = (el) => + getNumber(el.getAttribute('data-parallax-speed'), DEFAULT_SPEED); + + const getDistance = (el) => + getNumber(el.getAttribute('data-parallax-distance'), DEFAULT_DISTANCE); + + const update = () => { + if (activeElements.size === 0) { + rafId = null; + return; + } + + const vh = window.innerHeight || 1; + activeElements.forEach((el) => { + const rect = el.getBoundingClientRect(); + const progress = 1 - rect.top / vh; + const offset = progress * getDistance(el) * getSpeed(el); + el.style.transform = `translate3d(0, ${-offset}px, 0)`; + }); + + rafId = window.requestAnimationFrame(update); + }; + + const startLoop = () => { + if (rafId !== null) { + return; + } + rafId = window.requestAnimationFrame(update); + }; + + const stopLoop = () => { + if (rafId === null) { + return; + } + window.cancelAnimationFrame(rafId); + rafId = null; + }; + + const io = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + activeElements.add(entry.target); + } else { + activeElements.delete(entry.target); + } + }); + + if (activeElements.size > 0) { + startLoop(); + } else { + stopLoop(); + } + }, + { + root: null, + rootMargin: '20% 0px', + threshold: 0, + } + ); + + const observeElement = (el) => { + if (observedElements.has(el)) { + return; + } + observedElements.add(el); + el.style.willChange = 'transform'; + io.observe(el); + }; + + const unobserveElement = (el) => { + if (!observedElements.has(el)) { + return; + } + observedElements.delete(el); + activeElements.delete(el); + io.unobserve(el); + if (activeElements.size === 0) { + stopLoop(); + } + }; + + const scanNode = (node) => { + if (!(node instanceof Element)) { + return; + } + if (node.classList.contains(CLASS_NAME)) { + observeElement(node); + } + node.querySelectorAll(`.${CLASS_NAME}`).forEach(observeElement); + }; + + const unscanNode = (node) => { + if (!(node instanceof Element)) { + return; + } + if (node.classList.contains(CLASS_NAME)) { + unobserveElement(node); + } + node.querySelectorAll(`.${CLASS_NAME}`).forEach(unobserveElement); + }; + + const init = () => { + if (prefersReducedMotion.matches) { + return; + } + document.querySelectorAll(`.${CLASS_NAME}`).forEach(observeElement); + }; + + const mo = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach(scanNode); + mutation.removedNodes.forEach(unscanNode); + } + if (mutation.type === 'attributes') { + const target = mutation.target; + if (!(target instanceof Element)) { + return; + } + if (target.classList.contains(CLASS_NAME)) { + observeElement(target); + } else { + unobserveElement(target); + } + } + }); + }); + + const start = () => { + init(); + mo.observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class'], + }); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); + } else { + start(); + } +})(); diff --git a/styles/general-styles.css b/styles/general-styles.css index cd4ecb0..4678271 100644 --- a/styles/general-styles.css +++ b/styles/general-styles.css @@ -165,3 +165,7 @@ body, width: 20px; height: 20px; } + +.parallax-text { + will-change: transform; +} From 1a379f18d07463ccb8029d8af86f2f2174351721 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Jan 2026 09:24:16 +0000 Subject: [PATCH 2/4] Clamp parallax range and baseline Co-authored-by: goodslyfox --- js/parallax-text.js | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/js/parallax-text.js b/js/parallax-text.js index 2861a7c..c57c2f5 100644 --- a/js/parallax-text.js +++ b/js/parallax-text.js @@ -1,7 +1,7 @@ (() => { const CLASS_NAME = 'parallax-text'; const DEFAULT_SPEED = 1; - const DEFAULT_DISTANCE = 1130; + const DEFAULT_RANGE = 100; const activeElements = new Set(); const observedElements = new Set(); let rafId = null; @@ -18,8 +18,20 @@ const getSpeed = (el) => getNumber(el.getAttribute('data-parallax-speed'), DEFAULT_SPEED); - const getDistance = (el) => - getNumber(el.getAttribute('data-parallax-distance'), DEFAULT_DISTANCE); + const getRange = (el) => { + const range = getNumber( + el.getAttribute('data-parallax-range'), + Number.NaN + ); + if (Number.isFinite(range)) { + return range; + } + return getNumber(el.getAttribute('data-parallax-distance'), DEFAULT_RANGE); + }; + + const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + + const startTops = new WeakMap(); const update = () => { if (activeElements.size === 0) { @@ -30,8 +42,11 @@ const vh = window.innerHeight || 1; activeElements.forEach((el) => { const rect = el.getBoundingClientRect(); - const progress = 1 - rect.top / vh; - const offset = progress * getDistance(el) * getSpeed(el); + const startTop = startTops.get(el); + const baseline = Number.isFinite(startTop) ? startTop : rect.top; + const delta = baseline - rect.top; + const range = getRange(el); + const offset = clamp(delta * getSpeed(el), -range, range); el.style.transform = `translate3d(0, ${-offset}px, 0)`; }); @@ -58,8 +73,10 @@ entries.forEach((entry) => { if (entry.isIntersecting) { activeElements.add(entry.target); + startTops.set(entry.target, entry.boundingClientRect.top); } else { activeElements.delete(entry.target); + startTops.delete(entry.target); } }); @@ -82,6 +99,7 @@ } observedElements.add(el); el.style.willChange = 'transform'; + el.style.transform = 'translate3d(0, 0px, 0)'; io.observe(el); }; From c91be2f0f29904c31633d3ac242ee54c77ce2f07 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Jan 2026 09:31:49 +0000 Subject: [PATCH 3/4] Stabilize parallax offset on scroll Co-authored-by: goodslyfox --- js/parallax-text.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/js/parallax-text.js b/js/parallax-text.js index c57c2f5..92efa68 100644 --- a/js/parallax-text.js +++ b/js/parallax-text.js @@ -32,6 +32,7 @@ const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); const startTops = new WeakMap(); + const lastOffsets = new WeakMap(); const update = () => { if (activeElements.size === 0) { @@ -39,14 +40,16 @@ return; } - const vh = window.innerHeight || 1; activeElements.forEach((el) => { const rect = el.getBoundingClientRect(); + const lastOffset = lastOffsets.get(el) || 0; + const naturalTop = rect.top + lastOffset; const startTop = startTops.get(el); - const baseline = Number.isFinite(startTop) ? startTop : rect.top; - const delta = baseline - rect.top; + const baseline = Number.isFinite(startTop) ? startTop : naturalTop; + const delta = baseline - naturalTop; const range = getRange(el); const offset = clamp(delta * getSpeed(el), -range, range); + lastOffsets.set(el, offset); el.style.transform = `translate3d(0, ${-offset}px, 0)`; }); @@ -77,6 +80,8 @@ } else { activeElements.delete(entry.target); startTops.delete(entry.target); + lastOffsets.delete(entry.target); + entry.target.style.transform = 'translate3d(0, 0px, 0)'; } }); @@ -100,6 +105,7 @@ observedElements.add(el); el.style.willChange = 'transform'; el.style.transform = 'translate3d(0, 0px, 0)'; + lastOffsets.set(el, 0); io.observe(el); }; @@ -109,6 +115,9 @@ } observedElements.delete(el); activeElements.delete(el); + startTops.delete(el); + lastOffsets.delete(el); + el.style.transform = 'translate3d(0, 0px, 0)'; io.unobserve(el); if (activeElements.size === 0) { stopLoop(); From a3149e3281e128e771f2eb417ee3a44c7cf1b9f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Jan 2026 10:10:26 +0000 Subject: [PATCH 4/4] Adjust parallax progress and direction Co-authored-by: goodslyfox --- js/parallax-text.js | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/js/parallax-text.js b/js/parallax-text.js index 92efa68..e1d0bad 100644 --- a/js/parallax-text.js +++ b/js/parallax-text.js @@ -2,6 +2,7 @@ const CLASS_NAME = 'parallax-text'; const DEFAULT_SPEED = 1; const DEFAULT_RANGE = 100; + const DEFAULT_DIRECTION = 'up'; const activeElements = new Set(); const observedElements = new Set(); let rafId = null; @@ -29,9 +30,14 @@ return getNumber(el.getAttribute('data-parallax-distance'), DEFAULT_RANGE); }; + const getDirection = (el) => { + const dir = + (el.getAttribute('data-parallax-direction') || DEFAULT_DIRECTION).toLowerCase(); + return dir === 'down' ? 1 : -1; + }; + const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); - const startTops = new WeakMap(); const lastOffsets = new WeakMap(); const update = () => { @@ -40,17 +46,24 @@ return; } + const vh = window.innerHeight || 1; activeElements.forEach((el) => { const rect = el.getBoundingClientRect(); const lastOffset = lastOffsets.get(el) || 0; - const naturalTop = rect.top + lastOffset; - const startTop = startTops.get(el); - const baseline = Number.isFinite(startTop) ? startTop : naturalTop; - const delta = baseline - naturalTop; + const naturalTop = rect.top - lastOffset; + const elementHeight = rect.height || 1; + const progress = clamp( + (vh - naturalTop) / (vh + elementHeight), + 0, + 1 + ); const range = getRange(el); - const offset = clamp(delta * getSpeed(el), -range, range); + const direction = getDirection(el); + const speed = getSpeed(el); + const rawOffset = (progress - 0.5) * 2 * range * direction * speed; + const offset = clamp(rawOffset, -range, range); lastOffsets.set(el, offset); - el.style.transform = `translate3d(0, ${-offset}px, 0)`; + el.style.transform = `translate3d(0, ${offset}px, 0)`; }); rafId = window.requestAnimationFrame(update); @@ -76,10 +89,8 @@ entries.forEach((entry) => { if (entry.isIntersecting) { activeElements.add(entry.target); - startTops.set(entry.target, entry.boundingClientRect.top); } else { activeElements.delete(entry.target); - startTops.delete(entry.target); lastOffsets.delete(entry.target); entry.target.style.transform = 'translate3d(0, 0px, 0)'; } @@ -115,7 +126,6 @@ } observedElements.delete(el); activeElements.delete(el); - startTops.delete(el); lastOffsets.delete(el); el.style.transform = 'translate3d(0, 0px, 0)'; io.unobserve(el);