patx/demo

document.documentElement.classList.add("js");

const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

/* ---------- Mobile nav toggle ---------- */

const navToggle = document.querySelector(".nav-toggle");
const siteNav = document.querySelector(".site-nav");

if (navToggle && siteNav) {
  navToggle.addEventListener("click", () => {
    const open = siteNav.classList.toggle("open");
    navToggle.setAttribute("aria-expanded", String(open));
  });
}

/* ---------- Staggered scroll reveal ---------- */

const revealEls = document.querySelectorAll(".reveal");

if ("IntersectionObserver" in window) {
  revealEls.forEach((el) => {
    if (el.dataset.delay) el.style.transitionDelay = el.dataset.delay + "ms";
  });
  const revealObserver = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          entry.target.classList.add("visible");
          revealObserver.unobserve(entry.target);
        }
      }
    },
    { threshold: 0.12, rootMargin: "0px 0px -4% 0px" }
  );
  revealEls.forEach((el) => revealObserver.observe(el));
} else {
  revealEls.forEach((el) => el.classList.add("visible"));
}

/* ---------- Count-up stats ---------- */

const countEls = document.querySelectorAll("[data-count]");

function runCount(el) {
  const target = parseInt(el.dataset.count, 10);
  const suffix = el.dataset.suffix || "";
  const duration = 1300;
  const start = performance.now();
  function frame(now) {
    const t = Math.min((now - start) / duration, 1);
    const eased = 1 - Math.pow(1 - t, 3);
    el.textContent = Math.round(target * eased) + suffix;
    if (t < 1) requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}

if (countEls.length) {
  if (reducedMotion || !("IntersectionObserver" in window)) {
    countEls.forEach((el) => {
      el.textContent = el.dataset.count + (el.dataset.suffix || "");
    });
  } else {
    const countObserver = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            runCount(entry.target);
            countObserver.unobserve(entry.target);
          }
        }
      },
      { threshold: 0.6 }
    );
    countEls.forEach((el) => countObserver.observe(el));
  }
}

/* ---------- Parallax drift (decorative cutouts) ---------- */

const parallaxItems = [];

if (!reducedMotion && "IntersectionObserver" in window) {
  document.querySelectorAll("[data-parallax]").forEach((el) => {
    parallaxItems.push({
      el,
      speed: parseFloat(el.dataset.parallax) || 0.15,
      rotate: parseFloat(el.dataset.rotate) || 0,
      top: 0,
      height: 0,
      y: 0,
      visible: false,
    });
  });
}

if (parallaxItems.length) {
  const gate = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        const item = parallaxItems.find((p) => p.el === entry.target);
        if (item) item.visible = entry.isIntersecting;
      }
    },
    { rootMargin: "20% 0px 20% 0px" }
  );
  parallaxItems.forEach((p) => gate.observe(p.el));

  function measure() {
    for (const p of parallaxItems) {
      const rect = p.el.getBoundingClientRect();
      p.top = rect.top + window.scrollY - p.y;
      p.height = rect.height;
    }
  }

  let ticking = false;

  function render() {
    ticking = false;
    const vh = window.innerHeight;
    const sc = window.scrollY;
    for (const p of parallaxItems) {
      if (!p.visible) continue;
      const fromCenter = p.top + p.height / 2 - sc - vh / 2;
      p.y = fromCenter * -p.speed;
      const deg = p.rotate * (fromCenter / vh);
      p.el.style.transform =
        "translate3d(0," + p.y.toFixed(1) + "px,0) rotate(" + deg.toFixed(2) + "deg)";
    }
  }

  function requestRender() {
    if (!ticking) {
      ticking = true;
      requestAnimationFrame(render);
    }
  }

  measure();
  render();
  window.addEventListener("scroll", requestRender, { passive: true });
  window.addEventListener("resize", () => {
    measure();
    requestRender();
  });
}

/* ---------- Smart header (hide on scroll down, show on scroll up) ---------- */

const header = document.querySelector(".site-header");

if (header && !reducedMotion) {
  let lastY = window.scrollY;
  let headerTicking = false;

  function updateHeader() {
    headerTicking = false;
    const y = window.scrollY;
    const navOpen = siteNav && siteNav.classList.contains("open");
    if (!navOpen && y > 180 && y > lastY + 6) {
      header.classList.add("header-hidden");
    } else if (y < lastY - 6 || y <= 180) {
      header.classList.remove("header-hidden");
    }
    lastY = y;
  }

  window.addEventListener(
    "scroll",
    () => {
      if (!headerTicking) {
        headerTicking = true;
        requestAnimationFrame(updateHeader);
      }
    },
    { passive: true }
  );
}