162 lines
4.6 KiB
JavaScript
162 lines
4.6 KiB
JavaScript
|
|
/**
|
||
|
|
* God Spark — Zensical Theme Kit (JS)
|
||
|
|
* Zero dependencies. ~1.5 KB minified.
|
||
|
|
*
|
||
|
|
* Features
|
||
|
|
* ─ IntersectionObserver scroll-reveal (class: .godspark-reveal)
|
||
|
|
* ─ Optional cursor glow (enable via [godspark-glow] attribute)
|
||
|
|
* ─ Reduced-motion fallback baked in
|
||
|
|
*/
|
||
|
|
|
||
|
|
;(function () {
|
||
|
|
'use strict';
|
||
|
|
|
||
|
|
/* ── Reduced-motion preference (shared) ── */
|
||
|
|
const prefersReducedMotion = window.matchMedia(
|
||
|
|
'(prefers-reduced-motion: reduce)'
|
||
|
|
).matches;
|
||
|
|
|
||
|
|
/* ── 1. Scroll Reveal via IntersectionObserver ── */
|
||
|
|
function initScrollReveal () {
|
||
|
|
if (prefersReducedMotion) return; // already handled in CSS
|
||
|
|
|
||
|
|
const reveals = document.querySelectorAll('.godspark-reveal');
|
||
|
|
|
||
|
|
if (!reveals.length) return;
|
||
|
|
|
||
|
|
// Respect any user-set threshold via CSS variable, default 12%
|
||
|
|
const threshold = parseFloat(
|
||
|
|
getComputedStyle(document.documentElement)
|
||
|
|
.getPropertyValue('--godspark-reveal-threshold')
|
||
|
|
) || 0.12;
|
||
|
|
|
||
|
|
const observer = new IntersectionObserver(
|
||
|
|
(entries) => {
|
||
|
|
entries.forEach((entry) => {
|
||
|
|
if (entry.isIntersecting) {
|
||
|
|
entry.target.classList.add('is-visible');
|
||
|
|
observer.unobserve(entry.target); // fire once
|
||
|
|
}
|
||
|
|
});
|
||
|
|
},
|
||
|
|
{ threshold, rootMargin: '0px 0px -40px 0px' }
|
||
|
|
);
|
||
|
|
|
||
|
|
reveals.forEach((el) => observer.observe(el));
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ── 2. Cursor Glow ── */
|
||
|
|
function initCursorGlow () {
|
||
|
|
// Only run if [godspark-glow] is present on <html> or <body>
|
||
|
|
if (!document.documentElement.hasAttribute('godspark-glow') &&
|
||
|
|
!document.body.hasAttribute('godspark-glow')) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (prefersReducedMotion) return; // CSS already hides it
|
||
|
|
|
||
|
|
// Add a class for the CSS ::after pseudo-element to pick up
|
||
|
|
document.documentElement.classList.add('godspark-glow-active');
|
||
|
|
|
||
|
|
const glow = document.querySelector('.godspark-glow-active::after');
|
||
|
|
// We track position via a CSS custom property on the root element,
|
||
|
|
// letting the ::after pseudo consume it.
|
||
|
|
const styleEl = document.createElement('style');
|
||
|
|
styleEl.id = 'godspark-glow-dynamic';
|
||
|
|
document.head.appendChild(styleEl);
|
||
|
|
|
||
|
|
let mouseX = -400;
|
||
|
|
let mouseY = -400;
|
||
|
|
let rafPending = false;
|
||
|
|
|
||
|
|
function setGlowPosition (x, y) {
|
||
|
|
mouseX = x;
|
||
|
|
mouseY = y;
|
||
|
|
updateGlowStyle();
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateGlowStyle () {
|
||
|
|
styleEl.textContent = `
|
||
|
|
:root {
|
||
|
|
--godspark-cursor-x: ${mouseX}px;
|
||
|
|
--godspark-cursor-y: ${mouseY}px;
|
||
|
|
}
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate a unique glow element per page to act as the cursor-follower
|
||
|
|
const cursorEl = document.createElement('div');
|
||
|
|
cursorEl.setAttribute('aria-hidden', 'true');
|
||
|
|
cursorEl.className = 'godspark-cursor-glow';
|
||
|
|
cursorEl.style.cssText = `
|
||
|
|
position: fixed;
|
||
|
|
left: ${mouseX}px;
|
||
|
|
top: ${mouseY}px;
|
||
|
|
width: 380px;
|
||
|
|
height: 380px;
|
||
|
|
border-radius: 50%;
|
||
|
|
pointer-events: none;
|
||
|
|
z-index: 999999;
|
||
|
|
background: radial-gradient(circle,
|
||
|
|
rgba(195,174,214,0.22) 0%,
|
||
|
|
rgba(184,169,232,0.08) 35%,
|
||
|
|
transparent 70%
|
||
|
|
);
|
||
|
|
transform: translate(-50%, -50%);
|
||
|
|
touch-action: none;
|
||
|
|
will-change: left, top;
|
||
|
|
transition: none;
|
||
|
|
`;
|
||
|
|
document.body.appendChild(cursorEl);
|
||
|
|
|
||
|
|
function onPointerMove (e) {
|
||
|
|
if (rafPending) return;
|
||
|
|
rafPending = true;
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||
|
|
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||
|
|
cursorEl.style.left = clientX + 'px';
|
||
|
|
cursorEl.style.top = clientY + 'px';
|
||
|
|
rafPending = false;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function onPointerLeave () {
|
||
|
|
cursorEl.style.opacity = '0';
|
||
|
|
}
|
||
|
|
|
||
|
|
function onPointerEnter () {
|
||
|
|
cursorEl.style.opacity = '1';
|
||
|
|
}
|
||
|
|
|
||
|
|
document.addEventListener('mousemove', onPointerMove, { passive: true });
|
||
|
|
document.addEventListener('touchmove', onPointerMove, { passive: true });
|
||
|
|
document.addEventListener('mouseleave', onPointerLeave);
|
||
|
|
document.addEventListener('mouseenter', onPointerEnter);
|
||
|
|
|
||
|
|
// Hide on idle after 4 s
|
||
|
|
let idleTimer;
|
||
|
|
function resetIdle () {
|
||
|
|
clearTimeout(idleTimer);
|
||
|
|
cursorEl.style.opacity = '1';
|
||
|
|
idleTimer = setTimeout(() => {
|
||
|
|
cursorEl.style.opacity = '0';
|
||
|
|
}, 4000);
|
||
|
|
}
|
||
|
|
document.addEventListener('mousemove', resetIdle, { passive: true });
|
||
|
|
resetIdle();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ── Boot ── */
|
||
|
|
if (document.readyState === 'loading') {
|
||
|
|
document.addEventListener('DOMContentLoaded', boot);
|
||
|
|
} else {
|
||
|
|
boot();
|
||
|
|
}
|
||
|
|
|
||
|
|
function boot () {
|
||
|
|
initScrollReveal();
|
||
|
|
initCursorGlow();
|
||
|
|
}
|
||
|
|
})();
|