add page transitions

This commit is contained in:
Ermin Zoronjic 2026-05-07 04:03:38 +02:00
parent dd841dc6d6
commit 5278df56f6
10 changed files with 282 additions and 118 deletions

View File

@ -14,6 +14,7 @@ import ScrollToTop from "./components/ScrollToTop";
import ShopDrawer from "./components/ShopDrawer";
import CartToast from "./components/CartToast";
import { ProductTransitionProvider } from "./components/ProductTransition";
import { PageTransitionProvider } from "./transitions/PageTransition";
import useLenisSmoothScroll from "./hooks/useLenisSmoothScroll";
import useScrollTextReveal from "./hooks/useScrollTextReveal";
import useButtonInteractions from "./hooks/useButtonInteractions";
@ -59,29 +60,31 @@ function App() {
return (
<ThemeProvider value={{ theme, isLight, toggleTheme }}>
<ProductTransitionProvider>
<ScrollToTop />
<PageTransitionProvider>
<ScrollToTop />
<a href="#main-content" className="skip-link">
Zum Inhalt springen
</a>
<a href="#main-content" className="skip-link">
Zum Inhalt springen
</a>
<div ref={routeContentRef} data-route-content>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/duft/:perfumeSlug" element={<ProductDetailPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/impressum" element={<ImpressumPage />} />
<Route path="/datenschutz" element={<DatenschutzPage />} />
<Route path="/support" element={<SupportPage />} />
<Route path="/discovery-set" element={<DiscoverySetPage />} />
<Route path="/small-batch" element={<SmallBatchPage />} />
</Routes>
</div>
<div ref={routeContentRef} data-route-content>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/duft/:perfumeSlug" element={<ProductDetailPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/impressum" element={<ImpressumPage />} />
<Route path="/datenschutz" element={<DatenschutzPage />} />
<Route path="/support" element={<SupportPage />} />
<Route path="/discovery-set" element={<DiscoverySetPage />} />
<Route path="/small-batch" element={<SmallBatchPage />} />
</Routes>
</div>
<ShopDrawer />
<CartToast />
<Footer flushTop={shouldFlushFooter} />
{showSupportChatbot && <SupportChatbot />}
<ShopDrawer />
<CartToast />
<Footer flushTop={shouldFlushFooter} />
{showSupportChatbot && <SupportChatbot />}
</PageTransitionProvider>
</ProductTransitionProvider>
</ThemeProvider>
);

View File

@ -1,6 +1,7 @@
import { useLayoutEffect } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { isPageTransitionActive } from "../transitions/PageTransition";
let pluginsRegistered = false;
@ -65,6 +66,115 @@ const restoreRevealWords = (element) => {
delete element.dataset.revealOriginalHtml;
};
function collectRevealItems(scope) {
const groups = gsap.utils.toArray("[data-reveal-group]", scope);
return groups.map((group) => ({
group,
items: Array.from(group.querySelectorAll("[data-reveal]")).filter(
(item) => item.closest("[data-reveal-group]") === group
),
}));
}
function hideRevealItems(scope) {
collectRevealItems(scope).forEach(({ items }) => {
items.forEach((item) => {
if (item.dataset.reveal === "lines") {
gsap.set(item, { autoAlpha: 0 });
} else {
gsap.set(item, { y: 36, autoAlpha: 0, force3D: true });
}
});
});
}
function setupReveals(scope) {
const preparedElements = [];
const ctx = gsap.context(() => {
const entries = collectRevealItems(scope);
entries.forEach(({ group, items }) => {
if (items.length === 0) {
return;
}
items.forEach((item) => {
if (item.dataset.reveal === "lines") {
gsap.set(item, { autoAlpha: 0 });
return;
}
gsap.set(item, {
y: 36,
autoAlpha: 0,
force3D: true,
});
});
ScrollTrigger.create({
trigger: group,
start: group.dataset.revealStart || "top 78%",
once: true,
onEnter: () => {
const timeline = gsap.timeline();
items.forEach((item, index) => {
const position = index === 0 ? 0 : "<0.16";
if (item.dataset.reveal === "lines") {
const words = createRevealWords(item);
if (words.length === 0) {
return;
}
preparedElements.push(item);
gsap.set(item, { autoAlpha: 1 });
gsap.set(words, {
yPercent: 115,
rotate: 2.2,
transformOrigin: "0% 100%",
force3D: true,
});
timeline.to(
words,
{
yPercent: 0,
rotate: 0,
duration: 1.08,
stagger: 0.065,
ease: "power4.out",
clearProps: "transform",
},
position
);
return;
}
timeline.to(
item,
{
y: 0,
autoAlpha: 1,
duration: 1.02,
ease: "power4.out",
clearProps: "transform,opacity,visibility",
},
position
);
});
},
});
});
ScrollTrigger.refresh();
}, scope);
return { ctx, preparedElements };
}
function useScrollTextReveal(scopeRef, dependencyKey = "") {
useLayoutEffect(() => {
const scope = scopeRef.current;
@ -79,96 +189,27 @@ function useScrollTextReveal(scopeRef, dependencyKey = "") {
registerGsap();
const preparedElements = [];
const ctx = gsap.context(() => {
const groups = gsap.utils.toArray("[data-reveal-group]");
let result = null;
let handleReady = null;
groups.forEach((group) => {
const items = Array.from(group.querySelectorAll("[data-reveal]")).filter(
(item) => item.closest("[data-reveal-group]") === group
);
if (items.length === 0) {
return;
}
items.forEach((item) => {
if (item.dataset.reveal === "lines") {
gsap.set(item, { autoAlpha: 0 });
return;
}
gsap.set(item, {
y: 36,
autoAlpha: 0,
force3D: true,
});
});
ScrollTrigger.create({
trigger: group,
start: group.dataset.revealStart || "top 86%",
once: true,
onEnter: () => {
const timeline = gsap.timeline();
items.forEach((item, index) => {
const position = index === 0 ? 0 : "<0.16";
if (item.dataset.reveal === "lines") {
const words = createRevealWords(item);
if (words.length === 0) {
return;
}
preparedElements.push(item);
gsap.set(item, { autoAlpha: 1 });
gsap.set(words, {
yPercent: 115,
rotate: 2.2,
transformOrigin: "0% 100%",
force3D: true,
});
timeline.to(
words,
{
yPercent: 0,
rotate: 0,
duration: 1.08,
stagger: 0.065,
ease: "power4.out",
clearProps: "transform",
},
position
);
return;
}
timeline.to(
item,
{
y: 0,
autoAlpha: 1,
duration: 1.02,
ease: "power4.out",
clearProps: "transform,opacity,visibility",
},
position
);
});
},
});
});
ScrollTrigger.refresh();
}, scope);
if (isPageTransitionActive()) {
hideRevealItems(scope);
handleReady = () => {
result = setupReveals(scope);
};
window.addEventListener("page-transition-ready", handleReady, { once: true });
} else {
result = setupReveals(scope);
}
return () => {
ctx.revert();
preparedElements.forEach((element) => restoreRevealWords(element));
if (handleReady) {
window.removeEventListener("page-transition-ready", handleReady);
}
if (result) {
result.ctx.revert();
result.preparedElements.forEach((element) => restoreRevealWords(element));
}
};
}, [scopeRef, dependencyKey]);
}

View File

@ -55,7 +55,7 @@ function AboutPage() {
</p>
</section>
<section className="about-pillars" data-reveal-group data-reveal-start="top 86%">
<section className="about-pillars" data-reveal-group data-reveal-start="top 78%">
<header className="about-section-head">
<span className="about-eyebrow" data-reveal="fade">
Unser Ansatz

View File

@ -88,9 +88,9 @@ function DiscoveryOrderPanel({ onBuy, panelRef }) {
function DiscoveryHero({ onBuy, panelRef }) {
return (
<section className="discovery-hero">
<section className="discovery-hero" data-reveal-group data-reveal-start="top 90%">
<div className="discovery-hero-stage">
<div className="discovery-hero-copy">
<div className="discovery-hero-copy" data-reveal="fade">
<span className="discovery-kicker">Discovery Set</span>
<h1>Der Einstieg</h1>
@ -101,7 +101,7 @@ function DiscoveryHero({ onBuy, panelRef }) {
</p>
</div>
<figure className="discovery-hero-visual">
<figure className="discovery-hero-visual" data-reveal="fade">
<img
src={DISCOVERY_SET_IMAGE}
alt="Atmos Discovery Set"
@ -283,7 +283,7 @@ function DiscoveryIncludedSection() {
const isActive = activeIndex === index;
return (
<li className="discovery-scroll-list-item" key={perfume.id}>
<li className="discovery-scroll-list-item" key={perfume.id} data-reveal-group data-reveal-start="top 84%">
<article
className={`discovery-scroll-item${
isActive ? " discovery-scroll-item--active" : ""
@ -291,9 +291,9 @@ function DiscoveryIncludedSection() {
aria-current={isActive ? "true" : undefined}
ref={(element) => setItemRef(element, index)}
>
<span className="discovery-product-index">{perfume.id}</span>
<span className="discovery-product-index" data-reveal="fade">{perfume.id}</span>
<div className="discovery-scroll-copy">
<div className="discovery-scroll-copy" data-reveal="fade">
<h3>{perfume.name}</h3>
<div className="discovery-scroll-line" aria-hidden="true" />
<div className="discovery-scroll-mobile-detail">

View File

@ -85,7 +85,7 @@ function ImpressumPage() {
</p>
</section>
<section className="legal-fact-grid" data-reveal-group data-reveal-start="top 88%">
<section className="legal-fact-grid" data-reveal-group data-reveal-start="top 80%">
{FACTS.map((fact) => (
<article className="legal-fact" key={fact.label} data-reveal="fade">
<span className="legal-eyebrow">{fact.label}</span>

View File

@ -539,7 +539,7 @@ function LandingPage() {
className="discovery-section"
id="testen"
data-reveal-group
data-reveal-start="top 82%"
data-reveal-start="top 74%"
>
<div className="discovery-copy">
<h2 data-reveal="lines">

View File

@ -140,7 +140,7 @@ function SmallBatchPage() {
<section
className="release-grid"
data-reveal-group
data-reveal-start="top 88%"
data-reveal-start="top 80%"
>
{state.releases.map((release) => (
<article className="release-card" key={release.name} data-reveal="fade">

View File

@ -83,7 +83,7 @@ function SupportPage() {
</aside>
</section>
<section className="support-topics" data-reveal-group data-reveal-start="top 88%">
<section className="support-topics" data-reveal-group data-reveal-start="top 80%">
{TOPICS.map((topic) => (
<article className="support-topic" key={topic.label} data-reveal="fade">
<span className="support-eyebrow">{topic.label}</span>

View File

@ -0,0 +1 @@
/* Page transition styles removed — fade is handled entirely by GSAP inline. */

View File

@ -0,0 +1,119 @@
import { createContext, useCallback, useContext, useLayoutEffect, useRef } from "react";
import { useLocation, useNavigate } from "react-router";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useProductTransition } from "./ProductTransitionContext";
const PageTransitionContext = createContext({ navigateWithTransition: () => {} });
export const usePageTransition = () => useContext(PageTransitionContext);
let transitionActive = false;
export const isPageTransitionActive = () => transitionActive;
const prefersReducedMotion = () =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const isInternalLink = (anchor) => {
if (!anchor || anchor.target === "_blank") return false;
const href = anchor.getAttribute("href");
if (!href || href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("#")) return false;
return true;
};
export function PageTransitionProvider({ children }) {
const navigate = useNavigate();
const location = useLocation();
const { phase: productPhase } = useProductTransition();
const tlRef = useRef(null);
const isTransitioning = useRef(false);
const navigateWithTransition = useCallback(
(to) => {
if (isTransitioning.current) return;
if (productPhase !== "idle") {
navigate(to);
return;
}
if (prefersReducedMotion()) {
navigate(to);
return;
}
const routeContent = document.querySelector("[data-route-content]");
if (!routeContent) {
navigate(to);
return;
}
isTransitioning.current = true;
transitionActive = true;
tlRef.current?.kill();
const tl = gsap.timeline({
onComplete: () => {
navigate(to);
window.scrollTo({ top: 0, behavior: "instant" });
requestAnimationFrame(() => {
requestAnimationFrame(() => {
gsap.fromTo(
routeContent,
{ autoAlpha: 0 },
{
autoAlpha: 1,
duration: 0.4,
ease: "power2.out",
onComplete: () => {
isTransitioning.current = false;
transitionActive = false;
gsap.set(routeContent, { clearProps: "opacity,visibility" });
window.dispatchEvent(new CustomEvent("page-transition-ready"));
},
}
);
});
});
},
});
tl.to(routeContent, {
autoAlpha: 0,
duration: 0.3,
ease: "power2.in",
});
tlRef.current = tl;
},
[navigate, productPhase]
);
useLayoutEffect(() => {
const handleClick = (event) => {
if (event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
const anchor = event.target.closest("a[href]");
if (!anchor || !isInternalLink(anchor)) return;
if (anchor.querySelector("[data-product-transition-source]")) return;
const href = anchor.getAttribute("href");
const resolved = new URL(href, window.location.origin);
if (resolved.pathname === location.pathname) return;
event.preventDefault();
event.stopPropagation();
navigateWithTransition(href);
};
document.addEventListener("click", handleClick, { capture: true });
return () => document.removeEventListener("click", handleClick, { capture: true });
}, [location.pathname, navigateWithTransition]);
return (
<PageTransitionContext.Provider value={{ navigateWithTransition }}>
{children}
</PageTransitionContext.Provider>
);
}