101 lines
3.3 KiB
JavaScript
101 lines
3.3 KiB
JavaScript
|
|
(() => {
|
||
|
|
'use strict';
|
||
|
|
|
||
|
|
const nav = document.getElementById('nav');
|
||
|
|
const burger = document.getElementById('burger');
|
||
|
|
const navLinks = document.querySelector('.nav__links');
|
||
|
|
const yearEl = document.getElementById('year');
|
||
|
|
const parallaxEls = Array.from(document.querySelectorAll('[data-parallax]'));
|
||
|
|
const revealEls = Array.from(document.querySelectorAll('.reveal'));
|
||
|
|
|
||
|
|
if (yearEl) yearEl.textContent = new Date().getFullYear();
|
||
|
|
|
||
|
|
/* ---- Sticky / scrolled nav ---- */
|
||
|
|
const onScroll = () => {
|
||
|
|
if (window.scrollY > 30) nav.classList.add('is-scrolled');
|
||
|
|
else nav.classList.remove('is-scrolled');
|
||
|
|
updateParallax();
|
||
|
|
};
|
||
|
|
|
||
|
|
/* ---- Mobile menu toggle ---- */
|
||
|
|
if (burger && navLinks) {
|
||
|
|
burger.addEventListener('click', () => {
|
||
|
|
const open = navLinks.classList.toggle('is-open');
|
||
|
|
burger.classList.toggle('is-open', open);
|
||
|
|
burger.setAttribute('aria-expanded', String(open));
|
||
|
|
});
|
||
|
|
navLinks.querySelectorAll('a').forEach((a) =>
|
||
|
|
a.addEventListener('click', () => {
|
||
|
|
navLinks.classList.remove('is-open');
|
||
|
|
burger.classList.remove('is-open');
|
||
|
|
burger.setAttribute('aria-expanded', 'false');
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Parallax (rAF-throttled) ---- */
|
||
|
|
let ticking = false;
|
||
|
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
|
|
|
||
|
|
function updateParallax() {
|
||
|
|
if (reduced) return;
|
||
|
|
if (ticking) return;
|
||
|
|
ticking = true;
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
const vh = window.innerHeight;
|
||
|
|
const vw = window.innerWidth;
|
||
|
|
parallaxEls.forEach((el) => {
|
||
|
|
const rect = el.getBoundingClientRect();
|
||
|
|
// Only process elements roughly in / near the viewport
|
||
|
|
if (rect.bottom < -vh || rect.top > vh * 2) return;
|
||
|
|
const speed = parseFloat(el.dataset.parallax) || 0.2;
|
||
|
|
// Distance of element center from viewport center
|
||
|
|
const center = rect.top + rect.height / 2 - vh / 2;
|
||
|
|
const offset = -center * speed;
|
||
|
|
// Use translate3d for GPU acceleration
|
||
|
|
el.style.transform = `translate3d(0, ${offset.toFixed(1)}px, 0)`;
|
||
|
|
});
|
||
|
|
ticking = false;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Scroll reveal via IntersectionObserver ---- */
|
||
|
|
if ('IntersectionObserver' in window) {
|
||
|
|
const io = new IntersectionObserver(
|
||
|
|
(entries) => {
|
||
|
|
entries.forEach((entry) => {
|
||
|
|
if (entry.isIntersecting) {
|
||
|
|
entry.target.classList.add('is-visible');
|
||
|
|
io.unobserve(entry.target);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
},
|
||
|
|
{ threshold: 0.15, rootMargin: '0px 0px -60px 0px' }
|
||
|
|
);
|
||
|
|
revealEls.forEach((el) => io.observe(el));
|
||
|
|
} else {
|
||
|
|
revealEls.forEach((el) => el.classList.add('is-visible'));
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Smooth-scroll offset for fixed nav ---- */
|
||
|
|
document.querySelectorAll('a[href^="#"]').forEach((a) => {
|
||
|
|
a.addEventListener('click', (e) => {
|
||
|
|
const id = a.getAttribute('href');
|
||
|
|
if (id.length < 2) return;
|
||
|
|
const target = document.querySelector(id);
|
||
|
|
if (!target) return;
|
||
|
|
e.preventDefault();
|
||
|
|
const navH = nav.offsetHeight;
|
||
|
|
const top = target.getBoundingClientRect().top + window.scrollY - navH + 4;
|
||
|
|
window.scrollTo({ top, behavior: 'smooth' });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---- Listeners ---- */
|
||
|
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||
|
|
window.addEventListener('resize', updateParallax);
|
||
|
|
// initial paint
|
||
|
|
onScroll();
|
||
|
|
updateParallax();
|
||
|
|
})();
|