109 lines
3.2 KiB
TypeScript
109 lines
3.2 KiB
TypeScript
"use client";
|
|
|
|
import { usePathname } from "next/navigation";
|
|
import { useEffect } from "react";
|
|
|
|
// Selectors for elements that should animate in on scroll (scoped to <main>).
|
|
const TARGETS = [
|
|
"h1", "h2", "h3", "h4",
|
|
".eyebrow", ".eyebrow-light",
|
|
"p",
|
|
".btn-primary", ".btn-ghost", ".link-arrow", ".chip",
|
|
'[class*="card"]', '[class*="__tile"]', '[class*="__item"]',
|
|
'[class*="__pillar"]', '[class*="__step"]', '[class*="__cat"]',
|
|
]
|
|
.map((s) => `main ${s}`)
|
|
.join(",");
|
|
|
|
// Sections that manage their own motion / must not be hidden.
|
|
const EXCLUDE = ".home-hero, .jbanner, .nav, .footer, nav, footer, header";
|
|
|
|
export default function ScrollReveal() {
|
|
const pathname = usePathname();
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
|
|
let cleanup = () => {};
|
|
const id = window.requestAnimationFrame(() => {
|
|
const all = Array.from(document.querySelectorAll<HTMLElement>(TARGETS)).filter(
|
|
(el) => !el.closest(EXCLUDE)
|
|
);
|
|
|
|
// Keep only the outermost targets so a card animates as one unit
|
|
// (its inner heading/paragraph don't animate separately).
|
|
const set = new Set(all);
|
|
const targets = all.filter((el) => {
|
|
let p = el.parentElement;
|
|
while (p) {
|
|
if (set.has(p)) return false;
|
|
p = p.parentElement;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Small stagger between siblings that share a parent.
|
|
const byParent = new Map<Element, HTMLElement[]>();
|
|
targets.forEach((el) => {
|
|
const parent = el.parentElement;
|
|
if (!parent) return;
|
|
const arr = byParent.get(parent) ?? [];
|
|
arr.push(el);
|
|
byParent.set(parent, arr);
|
|
});
|
|
byParent.forEach((arr) => {
|
|
arr.forEach((el, idx) => {
|
|
el.style.setProperty("--reveal-delay", `${Math.min(idx * 0.08, 0.4)}s`);
|
|
});
|
|
});
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
entry.target.classList.add("is-visible");
|
|
observer.unobserve(entry.target);
|
|
}
|
|
});
|
|
},
|
|
{ rootMargin: "0px 0px -8% 0px", threshold: 0.08 }
|
|
);
|
|
|
|
const variantFor = (el: HTMLElement) => {
|
|
const cls = el.className || "";
|
|
if (el.matches(".eyebrow, .eyebrow-light")) return "reveal--left";
|
|
if (
|
|
/card|__tile|__item|__pillar|__step|__cat/.test(
|
|
typeof cls === "string" ? cls : ""
|
|
)
|
|
) {
|
|
return "reveal--zoom";
|
|
}
|
|
return "reveal--up";
|
|
};
|
|
|
|
targets.forEach((el) => {
|
|
el.classList.add("reveal", variantFor(el));
|
|
const rect = el.getBoundingClientRect();
|
|
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
|
if (inView) {
|
|
// Already visible on load — reveal instantly, no hidden flash.
|
|
el.classList.add("is-visible");
|
|
} else {
|
|
observer.observe(el);
|
|
}
|
|
});
|
|
|
|
cleanup = () => observer.disconnect();
|
|
});
|
|
|
|
return () => {
|
|
window.cancelAnimationFrame(id);
|
|
cleanup();
|
|
};
|
|
}, [pathname]);
|
|
|
|
return null;
|
|
}
|