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..e1d0bad --- /dev/null +++ b/js/parallax-text.js @@ -0,0 +1,199 @@ +(() => { + 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; + + 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 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 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 lastOffsets = new WeakMap(); + + const update = () => { + if (activeElements.size === 0) { + rafId = null; + 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 elementHeight = rect.height || 1; + const progress = clamp( + (vh - naturalTop) / (vh + elementHeight), + 0, + 1 + ); + const range = getRange(el); + 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)`; + }); + + 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); + lastOffsets.delete(entry.target); + entry.target.style.transform = 'translate3d(0, 0px, 0)'; + } + }); + + 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'; + el.style.transform = 'translate3d(0, 0px, 0)'; + lastOffsets.set(el, 0); + io.observe(el); + }; + + const unobserveElement = (el) => { + if (!observedElements.has(el)) { + return; + } + observedElements.delete(el); + activeElements.delete(el); + lastOffsets.delete(el); + el.style.transform = 'translate3d(0, 0px, 0)'; + 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; +}