add responsive and scroll behaviour changes

This commit is contained in:
Ermin Zoronjic 2026-04-30 09:39:32 +02:00
parent 2eea587e8b
commit 283a7a5214
22 changed files with 294 additions and 268 deletions

View File

@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"gsap": "^3.14.2", "gsap": "^3.14.2",
"lenis": "^1.3.23",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router": "^7.14.0" "react-router": "^7.14.0"
@ -1778,6 +1779,37 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/lenis": {
"version": "1.3.23",
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz",
"integrity": "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==",
"license": "MIT",
"workspaces": [
"packages/*",
"playground",
"playground/*"
],
"funding": {
"type": "github",
"url": "https://github.com/sponsors/darkroomengineering"
},
"peerDependencies": {
"@nuxt/kit": ">=3.0.0",
"react": ">=17.0.0",
"vue": ">=3.0.0"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
},
"react": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",

View File

@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"gsap": "^3.14.2", "gsap": "^3.14.2",
"lenis": "^1.3.23",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router": "^7.14.0" "react-router": "^7.14.0"

View File

@ -14,6 +14,7 @@ import ScrollToTop from "./components/ScrollToTop";
import ShopDrawer from "./components/ShopDrawer"; import ShopDrawer from "./components/ShopDrawer";
import CartToast from "./components/CartToast"; import CartToast from "./components/CartToast";
import { ProductTransitionProvider } from "./components/ProductTransition"; import { ProductTransitionProvider } from "./components/ProductTransition";
import useLenisSmoothScroll from "./hooks/useLenisSmoothScroll";
import useScrollTextReveal from "./hooks/useScrollTextReveal"; import useScrollTextReveal from "./hooks/useScrollTextReveal";
import { ThemeProvider } from "./theme/ThemeContext"; import { ThemeProvider } from "./theme/ThemeContext";
import "./style/textReveal.css"; import "./style/textReveal.css";
@ -30,7 +31,9 @@ function App() {
}); });
const shouldFlushFooter = const shouldFlushFooter =
location.pathname === "/" || location.pathname.startsWith("/duft/"); location.pathname === "/" || location.pathname.startsWith("/duft/");
const showSupportChatbot = location.pathname === "/";
useLenisSmoothScroll(location.pathname);
useScrollTextReveal(routeContentRef, location.pathname); useScrollTextReveal(routeContentRef, location.pathname);
useEffect(() => { useEffect(() => {
@ -72,7 +75,7 @@ function App() {
<ShopDrawer /> <ShopDrawer />
<CartToast /> <CartToast />
<Footer flushTop={shouldFlushFooter} /> <Footer flushTop={shouldFlushFooter} />
<SupportChatbot /> {showSupportChatbot && <SupportChatbot />}
</ProductTransitionProvider> </ProductTransitionProvider>
</ThemeProvider> </ThemeProvider>
); );

View File

@ -5,87 +5,17 @@
background: var(--theme-bg); background: var(--theme-bg);
} }
.detail-topbar {
position: fixed;
top: clamp(0.75rem, 2.1vw, 1.4rem);
right: var(--page-x);
left: var(--page-x);
z-index: 997;
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--gap-sm);
min-height: clamp(3rem, 5.4vw, 3.5rem);
padding: 0;
border-bottom: 0;
pointer-events: none;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.65rem;
min-height: 44px;
padding: 0.35rem 0;
border: 0;
background: transparent;
color: var(--theme-text);
cursor: pointer;
font-size: var(--text-sm);
pointer-events: auto;
transition:
transform var(--duration-med) var(--ease-out),
opacity var(--duration-med) var(--ease-out);
}
.back-link:hover {
opacity: 0.68;
transform: translateX(-0.2rem);
}
.back-link-arrow {
display: inline-block;
width: 1rem;
height: 1rem;
flex: 0 0 auto;
background: currentColor;
-webkit-mask: url("/icon-arrow-left.svg") center / contain no-repeat;
mask: url("/icon-arrow-left.svg") center / contain no-repeat;
}
.detail-topbar-meta {
display: none;
align-items: baseline;
justify-content: flex-end;
gap: var(--gap-xs);
min-width: 0;
flex-wrap: wrap;
text-align: right;
text-transform: uppercase;
pointer-events: auto;
}
.detail-topbar-meta span,
.detail-topbar-meta strong,
.eyebrow, .eyebrow,
.label-title { .label-title {
font-size: var(--text-xs); font-size: var(--text-xs);
letter-spacing: 0.22em; letter-spacing: 0.22em;
} }
.detail-topbar-meta span,
.eyebrow, .eyebrow,
.label-title { .label-title {
color: var(--theme-text-muted); color: var(--theme-text-muted);
} }
.detail-topbar-meta strong {
color: var(--theme-text);
font-weight: 500;
max-width: 100%;
overflow-wrap: anywhere;
}
.product-hero { .product-hero {
position: relative; position: relative;
display: grid; display: grid;
@ -359,9 +289,11 @@
display: block; display: block;
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
color: var(--theme-text); color: var(--theme-text);
font-size: var(--text-xl); font-size: clamp(1.05rem, 1.4vw, 1.38rem);
font-weight: 400; font-weight: 400;
letter-spacing: 0; letter-spacing: 0;
line-height: 1.05;
white-space: nowrap;
} }
.size-card small { .size-card small {
@ -407,7 +339,7 @@
justify-content: center; justify-content: center;
min-height: 44px; min-height: 44px;
padding: 0 1rem; padding: 0 1rem;
border-radius: 999px; border-radius: var(--radius-lg);
background: var(--theme-accent); background: var(--theme-accent);
color: #fff; color: #fff;
text-decoration: none; text-decoration: none;
@ -430,6 +362,7 @@
.review-write-button { .review-write-button {
min-height: 48px; min-height: 48px;
border: 1px solid var(--theme-border); border: 1px solid var(--theme-border);
border-radius: var(--radius-lg);
cursor: pointer; cursor: pointer;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.12em; letter-spacing: 0.12em;
@ -551,7 +484,7 @@
.character-facts { .character-facts {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--gap-sm); gap: var(--gap-sm);
margin-top: clamp(1.4rem, 3vw, 2.4rem); margin-top: clamp(1.4rem, 3vw, 2.4rem);
max-width: 58rem; max-width: 58rem;
@ -939,14 +872,22 @@
} }
.detail-bottom-actions button { .detail-bottom-actions button {
padding: 0 1.05rem; padding: 0 clamp(1rem, 2vw, 1.35rem);
border-color: rgba(255, 255, 255, 0.18); border: 0;
border-radius: 999px; border-radius: var(--radius-lg);
background: #262626; background: #fff;
color: #fff; color: var(--theme-accent);
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.18);
font-size: var(--text-sm);
letter-spacing: 0;
text-transform: none;
white-space: nowrap; white-space: nowrap;
} }
.detail-bottom-actions button:active {
transform: translateY(0) scale(0.98);
}
.recommendation-heading { .recommendation-heading {
display: block; display: block;
max-width: 74rem; max-width: 74rem;
@ -1128,19 +1069,6 @@
padding: 0; padding: 0;
} }
.detail-topbar {
top: clamp(4.35rem, 14vw, 5.05rem);
z-index: 999;
right: clamp(0.75rem, 4vw, 1rem);
left: clamp(0.75rem, 4vw, 1rem);
align-items: center;
flex-direction: row;
}
.detail-topbar-meta {
display: none;
}
.product-hero { .product-hero {
grid-template-columns: 1fr; grid-template-columns: 1fr;
align-content: start; align-content: start;

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router"; import { Link, useParams } from "react-router";
import perfumes from "../data/perfumes"; import perfumes from "../data/perfumes";
import SharedNavbar from "./SharedNavbar"; import SharedNavbar from "./SharedNavbar";
import { useProductTransition } from "../transitions/ProductTransitionContext"; import { useProductTransition } from "../transitions/ProductTransitionContext";
@ -14,6 +14,16 @@ const priceToCents = (price) => {
return match ? Number(match[1]) * 100 : 0; return match ? Number(match[1]) * 100 : 0;
}; };
const getFullSizeImage = (perfume) => perfume.gallery?.[0] || perfume.image;
const getSampleImage = (perfume) =>
perfume.gallery?.find((image) => image.toLowerCase().includes("sample")) ||
perfume.gallery?.[1] ||
perfume.image;
const getImageForSize = (perfume, size) =>
size === "sample" ? getSampleImage(perfume) : getFullSizeImage(perfume);
function ProductPurchasePanel({ function ProductPurchasePanel({
perfume, perfume,
selectedSize, selectedSize,
@ -26,18 +36,18 @@ function ProductPurchasePanel({
const selectedProductId = `${perfume.slug}-${selectedSize === "sample" ? "sample" : "full"}`; const selectedProductId = `${perfume.slug}-${selectedSize === "sample" ? "sample" : "full"}`;
const selectedProductLabel = selectedSize === "sample" ? "Sample" : "Full Size"; const selectedProductLabel = selectedSize === "sample" ? "Sample" : "Full Size";
const sizeOptions = [ const sizeOptions = [
{
key: "sample",
title: "Sample 2ml",
price: perfume.prices.sample,
note: "Zum Testen, ca. 20 Anwendungen",
},
{ {
key: "full", key: "full",
title: "Full Size 50ml", title: "Full Size 50ml",
price: perfume.prices.full, price: perfume.prices.full,
note: "Nachkauf, 500+ Anwendungen", note: "Nachkauf, 500+ Anwendungen",
}, },
{
key: "sample",
title: "Sample 2ml",
price: perfume.prices.sample,
note: "Zum Testen, ca. 20 Anwendungen",
},
]; ];
return ( return (
@ -201,10 +211,6 @@ function ProductStorySection({ perfume }) {
<span>Anlass</span> <span>Anlass</span>
<p>{perfume.occasion}</p> <p>{perfume.occasion}</p>
</div> </div>
<div>
<span>Lieferung</span>
<p>Versand in 1-2 Werktagen. Zustellung in der Regel in 5-6 Tagen.</p>
</div>
</div> </div>
</div> </div>
@ -469,7 +475,6 @@ function ProductRecommendations({ currentSlug, startProductTransition }) {
} }
function ProductDetailContent({ perfumeSlug }) { function ProductDetailContent({ perfumeSlug }) {
const navigate = useNavigate();
const { addToCart, subscribeToProduct, user } = useShop(); const { addToCart, subscribeToProduct, user } = useShop();
const { activeSlug, phase, startProductTransition } = useProductTransition(); const { activeSlug, phase, startProductTransition } = useProductTransition();
@ -479,9 +484,9 @@ function ProductDetailContent({ perfumeSlug }) {
); );
const [selectedImage, setSelectedImage] = useState( const [selectedImage, setSelectedImage] = useState(
perfume.gallery?.[0] || perfume.image getImageForSize(perfume, "full")
); );
const [selectedSize, setSelectedSize] = useState("sample"); const [selectedSize, setSelectedSize] = useState("full");
const [showReviewDetails, setShowReviewDetails] = useState(false); const [showReviewDetails, setShowReviewDetails] = useState(false);
const [commentPage, setCommentPage] = useState(0); const [commentPage, setCommentPage] = useState(0);
const selectedPriceCents = priceToCents(perfume.prices[selectedSize]); const selectedPriceCents = priceToCents(perfume.prices[selectedSize]);
@ -527,6 +532,11 @@ function ProductDetailContent({ perfumeSlug }) {
const isTransitionArriving = activeSlug === perfume.slug && phase === "entering"; const isTransitionArriving = activeSlug === perfume.slug && phase === "entering";
const handleSizeSelection = (size) => {
setSelectedSize(size);
setSelectedImage(getImageForSize(perfume, size));
};
useEffect(() => { useEffect(() => {
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
setCommentPage((prev) => (prev + 1) % safeCommentPages.length); setCommentPage((prev) => (prev + 1) % safeCommentPages.length);
@ -537,27 +547,15 @@ function ProductDetailContent({ perfumeSlug }) {
return ( return (
<div className={`detail-page ${isTransitionArriving ? "is-transition-arriving" : ""}`}> <div className={`detail-page ${isTransitionArriving ? "is-transition-arriving" : ""}`}>
<SharedNavbar variant="hero" /> <SharedNavbar variant="hero" brandMode="back" />
<main className="shell"> <main className="shell">
<div className="detail-topbar" data-product-transition-reveal>
<button className="back-link" type="button" onClick={() => navigate("/")}>
<span className="back-link-arrow" aria-hidden="true" />
<span>Zurück zur Startseite</span>
</button>
<div className="detail-topbar-meta">
<span>Duftdetail</span>
<strong>{perfume.name}</strong>
</div>
</div>
<ProductHero <ProductHero
perfume={perfume} perfume={perfume}
selectedImage={selectedImage} selectedImage={selectedImage}
setSelectedImage={setSelectedImage} setSelectedImage={setSelectedImage}
selectedSize={selectedSize} selectedSize={selectedSize}
setSelectedSize={setSelectedSize} setSelectedSize={handleSizeSelection}
selectedPriceCents={selectedPriceCents} selectedPriceCents={selectedPriceCents}
discountPreviewCents={discountPreviewCents} discountPreviewCents={discountPreviewCents}
addToCart={addToCart} addToCart={addToCart}

View File

@ -3,23 +3,33 @@ import { useShop } from "../shop/useShop";
import { useTheme } from "../theme/useTheme"; import { useTheme } from "../theme/useTheme";
import "../style/navbar.css"; import "../style/navbar.css";
function SharedNavbar({ variant = "hero", active = "" }) { function SharedNavbar({ variant = "hero", active = "", brandMode = "logo" }) {
const { cart, openCart, openProfile, user } = useShop(); const { cart, openCart, openProfile, user } = useShop();
const { isLight, toggleTheme } = useTheme(); const { isLight, toggleTheme } = useTheme();
const cartLabel = const cartLabel =
cart.total_quantity > 0 ? `Cart ${cart.total_quantity}` : "Cart"; cart.total_quantity > 0 ? `Cart ${cart.total_quantity}` : "Cart";
const logoSrc = const logoSrc =
variant === "hero" ? "/atmos-logo-light.svg" : "/atmos-logo-dark.svg"; variant === "hero" ? "/atmos-logo-light.svg" : "/atmos-logo-dark.svg";
const brandIsBack = brandMode === "back";
return ( return (
<nav className={`navbar navbar--${variant}`} aria-label="Hauptnavigation"> <nav className={`navbar navbar--${variant}`} aria-label="Hauptnavigation">
<div className="nav-pill"> <div className="nav-pill">
<Link <Link
to="/" to="/"
className={`nav-link nav-link--brand ${active === "atmos" ? "active" : ""}`} className={`nav-link nav-link--brand ${brandIsBack ? "nav-link--back" : ""} ${
aria-label="Atmos Startseite" active === "atmos" ? "active" : ""
}`}
aria-label={brandIsBack ? "Zur Startseite" : "Atmos Startseite"}
> >
<img src={logoSrc} alt="" className="nav-brand-logo" /> {brandIsBack ? (
<>
<span className="nav-back-icon" aria-hidden="true" />
<span>Zurück</span>
</>
) : (
<img src={logoSrc} alt="" className="nav-brand-logo" />
)}
</Link> </Link>
<Link <Link
to="/discovery-set" to="/discovery-set"

View File

@ -535,7 +535,7 @@
.cart-controls button, .cart-controls button,
.cart-toast button, .cart-toast button,
.subscription-row button { .subscription-row button {
border-radius: 999px; border-radius: var(--radius-lg);
} }
.drawer-primary, .drawer-primary,

View File

@ -460,7 +460,11 @@ function ShopDrawer() {
className={`drawer-backdrop ${panelOpen ? "open" : ""}`} className={`drawer-backdrop ${panelOpen ? "open" : ""}`}
onClick={closePanel} onClick={closePanel}
/> />
<aside className={`shop-drawer ${panelOpen ? "open" : ""}`} aria-hidden={!panelOpen}> <aside
className={`shop-drawer ${panelOpen ? "open" : ""}`}
aria-hidden={!panelOpen}
data-lenis-prevent
>
<div className="drawer-top"> <div className="drawer-top">
<span>{!user ? "ACCOUNT" : panelType === "cart" ? "CART" : "PROFILE"}</span> <span>{!user ? "ACCOUNT" : panelType === "cart" ? "CART" : "PROFILE"}</span>
<button type="button" onClick={closePanel} aria-label="Close panel"> <button type="button" onClick={closePanel} aria-label="Close panel">

View File

@ -278,12 +278,17 @@
/* --- Design System Refinement Start --- */ /* --- Design System Refinement Start --- */
.chatbot-trigger { .chatbot-trigger {
right: max(0.9rem, env(safe-area-inset-right)); --chatbot-size: 48px;
right: max(
0.75rem,
calc((100vw - var(--container-wide)) * 0.25 - (var(--chatbot-size) * 0.5)),
env(safe-area-inset-right)
);
bottom: max(0.9rem, env(safe-area-inset-bottom)); bottom: max(0.9rem, env(safe-area-inset-bottom));
width: 52px; width: var(--chatbot-size);
height: 52px; height: var(--chatbot-size);
min-height: 52px; min-height: var(--chatbot-size);
min-width: 52px; min-width: var(--chatbot-size);
padding: 0; padding: 0;
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
@ -298,8 +303,8 @@
.chatbot-trigger-icon, .chatbot-trigger-icon,
.chatbot-close-icon { .chatbot-close-icon {
display: block; display: block;
width: 1.45rem; width: 1.32rem;
height: 1.45rem; height: 1.32rem;
background: currentColor; background: currentColor;
-webkit-mask-position: center; -webkit-mask-position: center;
mask-position: center; mask-position: center;
@ -353,7 +358,7 @@
} }
.chatbot-send { .chatbot-send {
border-radius: 999px; border-radius: var(--radius-lg);
} }
.chatbot-chip, .chatbot-chip,

View File

@ -220,7 +220,12 @@ function SupportChatbot() {
</button> </button>
{isOpen && ( {isOpen && (
<div className="chatbot-window" role="dialog" aria-label="Support Chat"> <div
className="chatbot-window"
role="dialog"
aria-label="Support Chat"
data-lenis-prevent
>
<div className="chatbot-header"> <div className="chatbot-header">
<div className="chatbot-header-copy"> <div className="chatbot-header-copy">
<span className="chatbot-kicker">atmos SUPPORT</span> <span className="chatbot-kicker">atmos SUPPORT</span>

View File

@ -0,0 +1,79 @@
import { useEffect, useRef } from "react";
import Lenis from "lenis";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
let scrollTriggerRegistered = false;
const registerScrollTrigger = () => {
if (!scrollTriggerRegistered) {
gsap.registerPlugin(ScrollTrigger);
scrollTriggerRegistered = true;
}
};
function useLenisSmoothScroll(dependencyKey = "") {
const lenisRef = useRef(null);
useEffect(() => {
if (typeof window === "undefined") {
return undefined;
}
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReducedMotion) {
return undefined;
}
registerScrollTrigger();
const lenis = new Lenis({
anchors: {
duration: 1.05,
easing: (time) => 1 - Math.pow(1 - time, 4),
},
duration: 1.1,
easing: (time) => Math.min(1, 1.001 - Math.pow(2, -10 * time)),
lerp: 0.09,
smoothWheel: true,
syncTouch: false,
wheelMultiplier: 0.9,
});
lenisRef.current = lenis;
const updateScrollTrigger = () => ScrollTrigger.update();
const raf = (time) => {
lenis.raf(time * 1000);
};
lenis.on("scroll", updateScrollTrigger);
gsap.ticker.add(raf);
gsap.ticker.lagSmoothing(0);
return () => {
lenis.off("scroll", updateScrollTrigger);
gsap.ticker.remove(raf);
lenis.destroy();
lenisRef.current = null;
};
}, []);
useEffect(() => {
if (typeof window === "undefined") {
return undefined;
}
const frame = window.requestAnimationFrame(() => {
lenisRef.current?.scrollTo(0, { immediate: true, force: true });
window.scrollTo(0, 0);
ScrollTrigger.refresh();
});
return () => window.cancelAnimationFrame(frame);
}, [dependencyKey]);
}
export default useLenisSmoothScroll;

View File

@ -11,13 +11,13 @@ const registerGsap = () => {
} }
}; };
const createRevealLines = (element) => { const createRevealWords = (element) => {
if (!element) { if (!element) {
return []; return [];
} }
if (element.dataset.revealPrepared === "true") { if (element.dataset.revealPrepared === "true") {
return Array.from(element.querySelectorAll(".reveal-line")); return Array.from(element.querySelectorAll(".reveal-word"));
} }
const originalHtml = element.innerHTML; const originalHtml = element.innerHTML;
@ -33,16 +33,24 @@ const createRevealLines = (element) => {
element.dataset.revealPrepared = "true"; element.dataset.revealPrepared = "true";
element.dataset.revealOriginalHtml = originalHtml; element.dataset.revealOriginalHtml = originalHtml;
element.innerHTML = segments element.innerHTML = segments
.map( .map((segment) => {
(segment) => const words = segment
`<span class="reveal-line-mask"><span class="reveal-line">${segment}</span></span>` .split(/\s+/)
) .filter(Boolean)
.map(
(word) =>
`<span class="reveal-word-mask"><span class="reveal-word">${word}</span></span>`
)
.join(" ");
return `<span class="reveal-line-mask reveal-word-line">${words}</span>`;
})
.join(""); .join("");
return Array.from(element.querySelectorAll(".reveal-line")); return Array.from(element.querySelectorAll(".reveal-word"));
}; };
const restoreRevealLines = (element) => { const restoreRevealWords = (element) => {
if (!element || element.dataset.revealPrepared !== "true") { if (!element || element.dataset.revealPrepared !== "true") {
return; return;
} }
@ -108,15 +116,15 @@ function useScrollTextReveal(scopeRef, dependencyKey = "") {
const position = index === 0 ? 0 : "<0.16"; const position = index === 0 ? 0 : "<0.16";
if (item.dataset.reveal === "lines") { if (item.dataset.reveal === "lines") {
const lines = createRevealLines(item); const words = createRevealWords(item);
if (lines.length === 0) { if (words.length === 0) {
return; return;
} }
preparedElements.push(item); preparedElements.push(item);
gsap.set(item, { autoAlpha: 1 }); gsap.set(item, { autoAlpha: 1 });
gsap.set(lines, { gsap.set(words, {
yPercent: 115, yPercent: 115,
rotate: 2.2, rotate: 2.2,
transformOrigin: "0% 100%", transformOrigin: "0% 100%",
@ -124,12 +132,12 @@ function useScrollTextReveal(scopeRef, dependencyKey = "") {
}); });
timeline.to( timeline.to(
lines, words,
{ {
yPercent: 0, yPercent: 0,
rotate: 0, rotate: 0,
duration: 1.18, duration: 1.08,
stagger: 0.1, stagger: 0.065,
ease: "power4.out", ease: "power4.out",
clearProps: "transform", clearProps: "transform",
}, },
@ -160,7 +168,7 @@ function useScrollTextReveal(scopeRef, dependencyKey = "") {
return () => { return () => {
ctx.revert(); ctx.revert();
preparedElements.forEach((element) => restoreRevealLines(element)); preparedElements.forEach((element) => restoreRevealWords(element));
}; };
}, [scopeRef, dependencyKey]); }, [scopeRef, dependencyKey]);
} }

View File

@ -69,7 +69,7 @@
} }
html { html {
scroll-behavior: smooth; scroll-behavior: auto;
} }
body { body {

View File

@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router"; import { BrowserRouter } from "react-router";
import App from "./App"; import App from "./App";
import { ShopProvider } from "./shop/ShopContext"; import { ShopProvider } from "./shop/ShopContext";
import "lenis/dist/lenis.css";
import "./index.css"; import "./index.css";
import "./App.css"; import "./App.css";

View File

@ -304,7 +304,7 @@
min-height: 48px; min-height: 48px;
padding: 0 1.1rem; padding: 0 1.1rem;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 999px; border-radius: var(--radius-lg);
color: inherit; color: inherit;
font-size: var(--text-sm); font-size: var(--text-sm);
text-decoration: none; text-decoration: none;

View File

@ -5,50 +5,6 @@
background: var(--theme-bg); background: var(--theme-bg);
} }
.discovery-topbar {
position: fixed;
top: clamp(0.75rem, 2.1vw, 1.4rem);
right: var(--page-x);
left: var(--page-x);
z-index: 997;
min-height: 44px;
margin-bottom: 0;
pointer-events: none;
}
.discovery-back-link {
display: inline-flex;
align-items: center;
gap: 0.65rem;
min-height: 44px;
padding: 0;
border: 0;
background: transparent;
color: var(--theme-text);
cursor: pointer;
font-size: var(--text-sm);
pointer-events: auto;
transition:
opacity var(--duration-med) var(--ease-out),
transform var(--duration-med) var(--ease-out);
}
.discovery-back-link:hover,
.discovery-back-link:focus-visible {
opacity: 0.68;
transform: translateX(-0.2rem);
}
.discovery-back-arrow {
display: inline-block;
width: 1rem;
height: 1rem;
flex: 0 0 auto;
background: currentColor;
-webkit-mask: url("/icon-arrow-left.svg") center / contain no-repeat;
mask: url("/icon-arrow-left.svg") center / contain no-repeat;
}
.discovery-kicker, .discovery-kicker,
.discovery-label, .discovery-label,
.discovery-price-row span, .discovery-price-row span,
@ -88,7 +44,7 @@
top: clamp(6.5rem, 11vw, 9rem); top: clamp(6.5rem, 11vw, 9rem);
left: 0; left: 0;
z-index: 5; z-index: 5;
width: min(34vw, 540px); width: min(26vw, 390px);
min-width: 0; min-width: 0;
} }
@ -96,7 +52,7 @@
max-width: 9ch; max-width: 9ch;
margin: clamp(0.75rem, 1.5vw, 1.15rem) 0 clamp(1rem, 2vw, 1.4rem); margin: clamp(0.75rem, 1.5vw, 1.15rem) 0 clamp(1rem, 2vw, 1.4rem);
color: var(--theme-text); color: var(--theme-text);
font-size: clamp(3.35rem, 6.2vw, 7.6rem); font-size: clamp(2.6rem, 5.1vw, 6.4rem);
line-height: 0.9; line-height: 0.9;
font-weight: 300; font-weight: 300;
letter-spacing: 0; letter-spacing: 0;
@ -115,14 +71,14 @@
.discovery-hero-visual { .discovery-hero-visual {
position: relative; position: relative;
grid-column: 5 / 10; grid-column: 4 / 10;
grid-row: 1; grid-row: 1;
justify-self: center; justify-self: center;
align-self: center; align-self: center;
z-index: 1; z-index: 1;
display: grid; display: grid;
place-items: center; place-items: center;
justify-items: end; justify-items: center;
width: 100%; width: 100%;
min-height: inherit; min-height: inherit;
margin: 0; margin: 0;
@ -133,11 +89,11 @@
position: relative; position: relative;
z-index: 2; z-index: 2;
display: block; display: block;
width: min(100%, 520px); width: min(100%, 600px);
height: min(56svh, 640px); height: auto;
max-height: min(68svh, 760px); max-height: min(62svh, 700px);
border: 1px solid var(--theme-border); border: 0;
object-fit: cover; object-fit: contain;
object-position: center; object-position: center;
filter: saturate(0.92) contrast(1.04) drop-shadow(0 34px 72px rgba(0, 0, 0, 0.42)); filter: saturate(0.92) contrast(1.04) drop-shadow(0 34px 72px rgba(0, 0, 0, 0.42));
} }
@ -378,13 +334,12 @@
.discovery-benefit { .discovery-benefit {
display: grid; display: grid;
grid-template-columns: 1.75rem minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: var(--gap-sm); gap: var(--gap-sm);
align-items: start; align-items: start;
padding-top: 1rem; padding-top: 1rem;
} }
.discovery-benefit-icon,
.discovery-comparison-icon { .discovery-comparison-icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -663,8 +618,8 @@
} }
.discovery-hero-visual img { .discovery-hero-visual img {
width: min(100%, 480px); width: min(74%, 430px);
height: 100%; height: auto;
max-height: 100%; max-height: 100%;
} }
@ -688,13 +643,6 @@
} }
@media (max-width: 820px) { @media (max-width: 820px) {
.discovery-topbar {
top: clamp(4.35rem, 14vw, 5.05rem);
z-index: 999;
right: clamp(0.75rem, 4vw, 1rem);
left: clamp(0.75rem, 4vw, 1rem);
}
.discovery-hero { .discovery-hero {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: clamp(0.75rem, 2vw, 1rem); gap: clamp(0.75rem, 2vw, 1rem);
@ -735,7 +683,7 @@
} }
.discovery-hero-visual img { .discovery-hero-visual img {
width: 100%; width: min(82%, 320px);
} }
.discovery-panel-facts, .discovery-panel-facts,
@ -826,8 +774,7 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.discovery-primary-btn, .discovery-primary-btn,
.discovery-product-card, .discovery-product-card,
.discovery-product-image img, .discovery-product-image img {
.discovery-back-link {
transition: none; transition: none;
} }
} }

View File

@ -1,4 +1,3 @@
import { useNavigate } from "react-router";
import perfumes from "../data/perfumes"; import perfumes from "../data/perfumes";
import SharedNavbar from "../components/SharedNavbar"; import SharedNavbar from "../components/SharedNavbar";
import { useShop } from "../shop/useShop"; import { useShop } from "../shop/useShop";
@ -81,7 +80,7 @@ function DiscoveryOrderPanel({ onBuy }) {
<div className="discovery-panel-actions"> <div className="discovery-panel-actions">
<button type="button" className="discovery-primary-btn" onClick={onBuy}> <button type="button" className="discovery-primary-btn" onClick={onBuy}>
Discovery Set bestellen CHF 48. Kaufen
</button> </button>
<p>Nur das erste Set erstellt einen einmaligen CHF 48 Full-Size-Rabatt.</p> <p>Nur das erste Set erstellt einen einmaligen CHF 48 Full-Size-Rabatt.</p>
</div> </div>
@ -94,8 +93,8 @@ function DiscoveryHero({ onBuy }) {
<section className="discovery-hero"> <section className="discovery-hero">
<div className="discovery-hero-stage"> <div className="discovery-hero-stage">
<div className="discovery-hero-copy"> <div className="discovery-hero-copy">
<span className="discovery-kicker">Der Einstieg</span> <span className="discovery-kicker">Discovery Set</span>
<h1>Discovery Set</h1> <h1>Der Einstieg</h1>
<p className="discovery-intro"> <p className="discovery-intro">
6 Düfte × 2ml. Jeden Duft eine Woche tragen. Verstehen, was 6 Düfte × 2ml. Jeden Duft eine Woche tragen. Verstehen, was
@ -126,7 +125,7 @@ function DiscoveryStorySection() {
<span className="discovery-label" data-reveal="fade"> <span className="discovery-label" data-reveal="fade">
Warum Discovery Set Warum Discovery Set
</span> </span>
<h2 data-reveal="lines">Der klügere Einstieg in Nischendüfte.</h2> <h2 data-reveal="lines">Der klügere Einstieg.</h2>
<p data-reveal="fade"> <p data-reveal="fade">
Nischen-Parfums sind keine Impulskäufe. Sie brauchen Zeit, um zu Nischen-Parfums sind keine Impulskäufe. Sie brauchen Zeit, um zu
verstehen, wie sie auf deiner Haut funktionieren, wie sie sich im verstehen, wie sie auf deiner Haut funktionieren, wie sie sich im
@ -142,9 +141,6 @@ function DiscoveryStorySection() {
{discoveryBenefits.map((benefit) => ( {discoveryBenefits.map((benefit) => (
<article className="discovery-benefit" key={benefit.title}> <article className="discovery-benefit" key={benefit.title}>
<span className="discovery-benefit-icon" aria-hidden="true">
</span>
<div> <div>
<strong>{benefit.title}</strong> <strong>{benefit.title}</strong>
<p>{benefit.text}</p> <p>{benefit.text}</p>
@ -261,7 +257,7 @@ function DiscoveryFinalCta({ onBuy }) {
<div className="discovery-final-actions" data-reveal="fade"> <div className="discovery-final-actions" data-reveal="fade">
<button type="button" className="discovery-primary-btn" onClick={onBuy}> <button type="button" className="discovery-primary-btn" onClick={onBuy}>
Discovery Set bestellen CHF 48. Kaufen
</button> </button>
</div> </div>
</section> </section>
@ -269,7 +265,6 @@ function DiscoveryFinalCta({ onBuy }) {
} }
function DiscoverySetPage() { function DiscoverySetPage() {
const navigate = useNavigate();
const { addToCart } = useShop(); const { addToCart } = useShop();
const buyDiscoverySet = () => const buyDiscoverySet = () =>
addToCart("discovery-set", 1, "Discovery Set added.").catch(() => {}); addToCart("discovery-set", 1, "Discovery Set added.").catch(() => {});
@ -279,13 +274,6 @@ function DiscoverySetPage() {
<SharedNavbar variant="hero" active="testen" /> <SharedNavbar variant="hero" active="testen" />
<main className="shell"> <main className="shell">
<div className="discovery-topbar">
<button className="discovery-back-link" type="button" onClick={() => navigate("/")}>
<span className="discovery-back-arrow" aria-hidden="true" />
<span>Zurück zur Startseite</span>
</button>
</div>
<DiscoveryHero onBuy={buyDiscoverySet} /> <DiscoveryHero onBuy={buyDiscoverySet} />
<DiscoveryStorySection /> <DiscoveryStorySection />
<DiscoveryIncludedSection /> <DiscoveryIncludedSection />

View File

@ -140,7 +140,7 @@
.discovery-btn { .discovery-btn {
min-height: 48px; min-height: 48px;
border: none; border: none;
border-radius: 999px; border-radius: var(--radius-lg);
padding: 0 clamp(1rem, 2vw, 1.35rem); padding: 0 clamp(1rem, 2vw, 1.35rem);
font-size: var(--text-sm); font-size: var(--text-sm);
cursor: pointer; cursor: pointer;
@ -223,6 +223,7 @@
} }
.section-heading { .section-heading {
position: relative;
display: grid; display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr)); grid-template-columns: repeat(12, minmax(0, 1fr));
gap: var(--gap-md); gap: var(--gap-md);
@ -232,9 +233,11 @@
.section-heading::after { .section-heading::after {
content: "01 / Kollektion"; content: "01 / Kollektion";
grid-column: 9 / span 3; position: absolute;
align-self: start; top: 0;
padding-top: 0.3rem; right: 0;
width: min(32vw, 420px);
padding-top: 0.55rem;
border-top: 1px solid var(--theme-border); border-top: 1px solid var(--theme-border);
color: var(--theme-text-muted); color: var(--theme-text-muted);
font-size: var(--text-xs); font-size: var(--text-xs);
@ -591,6 +594,7 @@
.section-heading { .section-heading {
grid-template-columns: 1fr; grid-template-columns: 1fr;
padding-top: clamp(2.4rem, 6vw, 3.4rem);
} }
.section-heading h2, .section-heading h2,
@ -662,6 +666,12 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.section-heading::after {
right: auto;
left: 0;
width: min(100%, 24rem);
}
.product-card { .product-card {
min-height: clamp(340px, 118vw, 520px); min-height: clamp(340px, 118vw, 520px);
} }

View File

@ -78,7 +78,7 @@
margin-top: 1.2rem; margin-top: 1.2rem;
padding: 0 1.1rem; padding: 0 1.1rem;
border: 1px solid #111; border: 1px solid #111;
border-radius: 999px; border-radius: var(--radius-lg);
background: #111; background: #111;
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;

View File

@ -204,7 +204,7 @@
min-height: 48px; min-height: 48px;
margin-top: 1.15rem; margin-top: 1.15rem;
padding: 0 1.1rem; padding: 0 1.1rem;
border-radius: 999px; border-radius: var(--radius-lg);
background: var(--theme-accent); background: var(--theme-accent);
color: #fff; color: #fff;
font-size: var(--text-sm); font-size: var(--text-sm);
@ -268,7 +268,7 @@
min-height: 48px; min-height: 48px;
padding: 0 1.1rem; padding: 0 1.1rem;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 999px; border-radius: var(--radius-lg);
color: inherit; color: inherit;
font-size: var(--text-sm); font-size: var(--text-sm);
text-decoration: none; text-decoration: none;

View File

@ -47,35 +47,24 @@
transform var(--duration-med) var(--ease-out); transform var(--duration-med) var(--ease-out);
} }
.nav-link::after {
content: "";
position: absolute;
right: 0.8rem;
bottom: 0.45rem;
left: 0.8rem;
height: 1px;
background: currentColor;
opacity: 0;
transform: scaleX(0.35);
transform-origin: center;
transition:
opacity var(--duration-med) var(--ease-out),
transform var(--duration-med) var(--ease-out);
}
.nav-link:hover::after,
.nav-link.active::after {
opacity: 0.5;
transform: scaleX(1);
}
.nav-link--brand { .nav-link--brand {
padding-inline: clamp(0.75rem, 1.4vw, 1rem); padding-inline: clamp(0.75rem, 1.4vw, 1rem);
} }
.nav-link--brand::after, .nav-link--back {
.nav-theme-switch::after { gap: 0.5rem;
display: none; min-width: 0;
text-transform: none;
}
.nav-back-icon {
display: inline-block;
width: 0.95rem;
height: 0.95rem;
flex: 0 0 auto;
background: currentColor;
-webkit-mask: url("/icon-arrow-left.svg") center / contain no-repeat;
mask: url("/icon-arrow-left.svg") center / contain no-repeat;
} }
.nav-brand-logo { .nav-brand-logo {

View File

@ -5,12 +5,30 @@
margin-bottom: -0.08em; margin-bottom: -0.08em;
} }
.reveal-word-line {
overflow: visible;
}
.reveal-word-mask {
display: inline-block;
overflow: hidden;
vertical-align: top;
padding-bottom: 0.08em;
margin-bottom: -0.08em;
}
.reveal-line { .reveal-line {
display: block; display: block;
will-change: transform; will-change: transform;
backface-visibility: hidden; backface-visibility: hidden;
} }
.reveal-word {
display: inline-block;
will-change: transform;
backface-visibility: hidden;
}
[data-reveal="fade"] { [data-reveal="fade"] {
will-change: transform, opacity; will-change: transform, opacity;
} }