add page transitions
This commit is contained in:
parent
dd841dc6d6
commit
5278df56f6
@ -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,6 +60,7 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider value={{ theme, isLight, toggleTheme }}>
|
||||
<ProductTransitionProvider>
|
||||
<PageTransitionProvider>
|
||||
<ScrollToTop />
|
||||
|
||||
<a href="#main-content" className="skip-link">
|
||||
@ -82,6 +84,7 @@ function App() {
|
||||
<CartToast />
|
||||
<Footer flushTop={shouldFlushFooter} />
|
||||
{showSupportChatbot && <SupportChatbot />}
|
||||
</PageTransitionProvider>
|
||||
</ProductTransitionProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@ -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,29 +66,34 @@ const restoreRevealWords = (element) => {
|
||||
delete element.dataset.revealOriginalHtml;
|
||||
};
|
||||
|
||||
function useScrollTextReveal(scopeRef, dependencyKey = "") {
|
||||
useLayoutEffect(() => {
|
||||
const scope = scopeRef.current;
|
||||
|
||||
if (!scope || typeof window === "undefined") {
|
||||
return undefined;
|
||||
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
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
return undefined;
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
registerGsap();
|
||||
|
||||
function setupReveals(scope) {
|
||||
const preparedElements = [];
|
||||
const ctx = gsap.context(() => {
|
||||
const groups = gsap.utils.toArray("[data-reveal-group]");
|
||||
|
||||
groups.forEach((group) => {
|
||||
const items = Array.from(group.querySelectorAll("[data-reveal]")).filter(
|
||||
(item) => item.closest("[data-reveal-group]") === group
|
||||
);
|
||||
const entries = collectRevealItems(scope);
|
||||
|
||||
entries.forEach(({ group, items }) => {
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
@ -107,7 +113,7 @@ function useScrollTextReveal(scopeRef, dependencyKey = "") {
|
||||
|
||||
ScrollTrigger.create({
|
||||
trigger: group,
|
||||
start: group.dataset.revealStart || "top 86%",
|
||||
start: group.dataset.revealStart || "top 78%",
|
||||
once: true,
|
||||
onEnter: () => {
|
||||
const timeline = gsap.timeline();
|
||||
@ -166,9 +172,44 @@ function useScrollTextReveal(scopeRef, dependencyKey = "") {
|
||||
ScrollTrigger.refresh();
|
||||
}, scope);
|
||||
|
||||
return { ctx, preparedElements };
|
||||
}
|
||||
|
||||
function useScrollTextReveal(scopeRef, dependencyKey = "") {
|
||||
useLayoutEffect(() => {
|
||||
const scope = scopeRef.current;
|
||||
|
||||
if (!scope || typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
registerGsap();
|
||||
|
||||
let result = null;
|
||||
let handleReady = null;
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
1
parfum-shop/src/transitions/PageTransition.css
Normal file
1
parfum-shop/src/transitions/PageTransition.css
Normal file
@ -0,0 +1 @@
|
||||
/* Page transition styles removed — fade is handled entirely by GSAP inline. */
|
||||
119
parfum-shop/src/transitions/PageTransition.jsx
Normal file
119
parfum-shop/src/transitions/PageTransition.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user