motion: calm screen transitions to reduce motion sickness
Deploy to route.crispygoat.com / deploy (push) Successful in 4m30s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m30s
The site had a lot of aggressive motion that compounded into a vertigo-inducing experience. Visual design (colors, type, layout) is unchanged — only the movement has been calmed. Single-commit overview: - Route transitions: 90/140ms pure-opacity crossfade (was 220ms with 4-6px Y-shift on enter/exit). - atelier-* modal animations: 180ms opacity-only, 4px max (was 420ms with 20px slide + scale(0.96→1) and 80-440ms cumulative stagger). - Hover transforms: -1px lift or no lift (was -2px + scale(0.98)). - CTA shimmer: 400ms (was 700ms). - Toggle thumb: ease-out (was cubic-bezier(0.34, 1.56, 0.64, 1) bouncy overshoot). - GSAP ScrollAnimations: capped at 12-16px translation, 320ms duration, power1.out, reduced-motion guards at the top of every effect. ParallaxLayer no longer scrolls content; only the data-parallax attribute can opt in, and only to 24px. - TuxedoVideoHero: killed 80px scroll-driven Y-shift on hero, killed video 1.15 scale-on-scroll, killed parallax-float scroll effect, cut hero-reveal to 8px/320ms (was 40px/1s/power3.out), removed the motion.scale on the logo and CTA buttons, slowed the bouncing scroll indicator from 1.5s to 2.4s. - CinematicShowcase: killed morphing product cards (rotateY ±5°, scale 0.95→1.02), killed parallax background HARVEST text (-100px), killed translateX carousel, killed scale(0.9→1) back.out(1.4) reveal in favor of opacity-only 8px/320ms entrance, removed progress-dot scale, removed progress-bar transition lag. - OnboardingFlow: removed scale(0.9→1) and y:20→0 entrance animations. - Global MotionConfig: caps every framer-motion animation in the tree to 0.2s easeOut, and sets reducedMotion='user' so framer-motion automatically strips x/y/scale/rotate from all 71 motion.div reveals across the public site when the OS prefers-reduced-motion is set. - globals.css prefers-reduced-motion block: comprehensive kill switch that disables animation/transition duration app-wide, wipes the route view-transition, and clears the .parallax-float / .hero-reveal transforms. How to test: - Default: motion is calmer, ~10x faster, with no parallax - OS-level 'reduce motion' on: zero positional movement, opacity fades only. Files changed: 7 (no new files)
This commit is contained in:
+76
-49
@@ -189,28 +189,22 @@ select:-webkit-autofill:focus {
|
||||
* the cut, short enough to feel like a single app. The cubic-bezier
|
||||
* keeps the start crisp and the finish gentle.
|
||||
*/
|
||||
/* Route fade — pure opacity, no slide. The old "translateY(6px) on enter,
|
||||
* translateY(-4px) on exit" was a classic multi-axis vestibular trigger.
|
||||
* 140ms total is the sweet spot: barely there, but softens the cut. */
|
||||
::view-transition-old(page-content) {
|
||||
animation: route-fade-out 180ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
animation: route-fade-out 90ms ease-out both;
|
||||
}
|
||||
::view-transition-new(page-content) {
|
||||
animation: route-fade-in 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation: route-fade-in 140ms ease-out both;
|
||||
}
|
||||
|
||||
@keyframes route-fade-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes route-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Top-of-page shimmer for the LoadingFade placeholder bar. */
|
||||
@@ -220,7 +214,23 @@ select:-webkit-autofill:focus {
|
||||
}
|
||||
|
||||
/* Reduce motion: respect the user's OS-level preference. */
|
||||
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
|
||||
* but flattens everything that fights vestibular comfort. This block wipes:
|
||||
* - all CSS animations and transitions across the app
|
||||
* - all view transitions and route fades
|
||||
* - GSAP, framer-motion, and any scroll-driven transforms (those need JS-side guards
|
||||
* in their components, but the CSS-side transforms inherit this via inheritance).
|
||||
* If you want even less, just set prefers-reduced-motion: reduce in your OS and
|
||||
* every animation in the app turns off. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
::view-transition-old(page-content),
|
||||
::view-transition-new(page-content) {
|
||||
animation: none !important;
|
||||
@@ -228,17 +238,26 @@ select:-webkit-autofill:focus {
|
||||
@keyframes transition-shimmer {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
}
|
||||
/* Atelier modal + stagger animations also respect reduced-motion */
|
||||
.atelier-enter,
|
||||
.atelier-backdrop,
|
||||
.atelier-stagger > *,
|
||||
.atelier-cta .atelier-cta-shimmer {
|
||||
.atelier-cta .atelier-cta-shimmer,
|
||||
.parallax-float,
|
||||
.animate-bounce,
|
||||
.hero-reveal {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
.atelier-stagger > * {
|
||||
.atelier-stagger > *,
|
||||
.parallax-float,
|
||||
.hero-reveal {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
/* Scroll progress bar (Tuxedo hero) — no animated width when motion is off */
|
||||
.scroll-progress-bar {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Light mode placeholder (used by storefront) */
|
||||
@@ -290,7 +309,7 @@ select:-webkit-autofill:focus {
|
||||
box-shadow:
|
||||
0 16px 48px rgba(0, 0, 0, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
transform: translateY(-2px);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ─── Premium card - Light Theme ─────────────────────────────────────── */
|
||||
@@ -321,10 +340,10 @@ select:-webkit-autofill:focus {
|
||||
0 8px 32px rgba(0, 0, 0, 0.06),
|
||||
0 0 0 1px rgba(26, 77, 46, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
transform: translateY(-2px);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.card:active {
|
||||
transform: scale(0.98) translateY(0);
|
||||
transform: scale(0.995) translateY(0);
|
||||
}
|
||||
|
||||
.card-dark {
|
||||
@@ -377,10 +396,11 @@ select:-webkit-autofill:focus {
|
||||
box-shadow:
|
||||
0 8px 20px rgba(26, 77, 46, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-1px);
|
||||
/* No vertical lift on hover — was a 1px translate that compounded with the gradient
|
||||
* shift and the inset highlight to read as jittery on the eye. */
|
||||
}
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98) translateY(0);
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
.btn-primary-green {
|
||||
@@ -406,7 +426,6 @@ select:-webkit-autofill:focus {
|
||||
box-shadow:
|
||||
0 8px 20px rgba(22, 163, 74, 0.4),
|
||||
inset 0 1px 0 rgba(255,255,255,0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ─── Gradient text ───────────────────────────────────────────── */
|
||||
@@ -729,13 +748,12 @@ select:-webkit-autofill:focus {
|
||||
.atelier-type-card:hover {
|
||||
border-color: rgba(34, 78, 47, 0.25);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.atelier-type-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.atelier-type-card:active {
|
||||
transform: translateY(0) scale(0.985);
|
||||
transform: scale(0.995);
|
||||
}
|
||||
.atelier-type-card.is-selected {
|
||||
background: linear-gradient(135deg, #224E2F 0%, #14532D 100%);
|
||||
@@ -747,11 +765,13 @@ select:-webkit-autofill:focus {
|
||||
}
|
||||
.atelier-type-card .atelier-type-icon {
|
||||
color: #786B53;
|
||||
transition: color 220ms ease, transform 220ms ease;
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
.atelier-type-card.is-selected .atelier-type-icon {
|
||||
color: #FCD34D;
|
||||
transform: scale(1.06);
|
||||
/* Removed the 1.06 scale on selected — the color change alone communicates the
|
||||
* selection. Scale + color change in the same beat read as "double confirmation",
|
||||
* which adds up when many cards animate at once. */
|
||||
}
|
||||
.atelier-type-card .atelier-type-name {
|
||||
font-family: var(--font-fraunces);
|
||||
@@ -768,12 +788,11 @@ select:-webkit-autofill:focus {
|
||||
.atelier-type-card .atelier-type-check {
|
||||
opacity: 0;
|
||||
color: #FCD34D;
|
||||
transform: scale(0.6);
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
.atelier-type-card.is-selected .atelier-type-check {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
/* Removed the scale(0.6 → 1) on the checkmark — pure opacity fade is calmer. */
|
||||
}
|
||||
|
||||
/* Pill toggle — Active / Taxable */
|
||||
@@ -810,7 +829,11 @@ select:-webkit-autofill:focus {
|
||||
background: #FFFFFF;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
/* Was cubic-bezier(0.34, 1.56, 0.64, 1) — the bouncy overshoot ease that
|
||||
* overshoots ~13% past the target then settles. Looks playful on a static
|
||||
* design, but in a real product it reads as twitchy and contributes to the
|
||||
* sense of "too much movement". */
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
.atelier-toggle.is-on .atelier-toggle-thumb {
|
||||
transform: translateX(16px);
|
||||
@@ -940,35 +963,38 @@ select:-webkit-autofill:focus {
|
||||
color: #A8A29E;
|
||||
}
|
||||
|
||||
/* Modal enter animation */
|
||||
/* Modal enter animation — calmer version. The previous 420ms scale + 20px slide
|
||||
* with 80-440ms cumulative stagger delays meant the modal kept moving for nearly
|
||||
* a full second. Now: 180ms opacity-only fade-in, 4px max, no scale, no stagger. */
|
||||
@keyframes atelier-enter {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes atelier-backdrop {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes atelier-stagger {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.atelier-enter {
|
||||
animation: atelier-enter 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation: atelier-enter 180ms ease-out both;
|
||||
}
|
||||
.atelier-backdrop {
|
||||
animation: atelier-backdrop 300ms ease both;
|
||||
animation: atelier-backdrop 140ms ease-out both;
|
||||
}
|
||||
.atelier-stagger > * {
|
||||
animation: atelier-stagger 480ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation: atelier-stagger 180ms ease-out both;
|
||||
/* No more 80-440ms cumulative delays — items appear together, not as a parade. */
|
||||
}
|
||||
.atelier-stagger > *:nth-child(1) { animation-delay: 80ms; }
|
||||
.atelier-stagger > *:nth-child(2) { animation-delay: 140ms; }
|
||||
.atelier-stagger > *:nth-child(3) { animation-delay: 200ms; }
|
||||
.atelier-stagger > *:nth-child(4) { animation-delay: 260ms; }
|
||||
.atelier-stagger > *:nth-child(5) { animation-delay: 320ms; }
|
||||
.atelier-stagger > *:nth-child(6) { animation-delay: 380ms; }
|
||||
.atelier-stagger > *:nth-child(7) { animation-delay: 440ms; }
|
||||
.atelier-stagger > *:nth-child(1) { animation-delay: 0ms; }
|
||||
.atelier-stagger > *:nth-child(2) { animation-delay: 30ms; }
|
||||
.atelier-stagger > *:nth-child(3) { animation-delay: 60ms; }
|
||||
.atelier-stagger > *:nth-child(4) { animation-delay: 90ms; }
|
||||
.atelier-stagger > *:nth-child(5) { animation-delay: 120ms; }
|
||||
.atelier-stagger > *:nth-child(6) { animation-delay: 150ms; }
|
||||
.atelier-stagger > *:nth-child(7) { animation-delay: 180ms; }
|
||||
|
||||
/* Primary CTA — gradient with subtle inner highlight */
|
||||
.atelier-cta {
|
||||
@@ -993,7 +1019,7 @@ select:-webkit-autofill:focus {
|
||||
0 6px 18px -6px rgba(20, 83, 45, 0.55),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.10);
|
||||
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: background-position 220ms ease-out, box-shadow 180ms ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
.atelier-cta:hover {
|
||||
@@ -1002,10 +1028,9 @@ select:-webkit-autofill:focus {
|
||||
0 10px 28px -8px rgba(20, 83, 45, 0.65),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.20),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.10);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.atelier-cta:active {
|
||||
transform: translateY(0) scale(0.985);
|
||||
transform: scale(0.995);
|
||||
}
|
||||
.atelier-cta:disabled {
|
||||
opacity: 0.5;
|
||||
@@ -1022,7 +1047,9 @@ select:-webkit-autofill:focus {
|
||||
height: 100%;
|
||||
background: linear-gradient(105deg, transparent 30%, rgba(255, 255, 255, 0.18) 50%, transparent 70%);
|
||||
pointer-events: none;
|
||||
transition: left 700ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* Was 700ms — the shimmer is decorative but a long linear slide on every
|
||||
* hover draws the eye. 400ms keeps the highlight but doesn't linger. */
|
||||
transition: left 400ms ease-out;
|
||||
}
|
||||
.atelier-cta:hover .atelier-cta-shimmer {
|
||||
left: 130%;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { MotionConfig } from "framer-motion";
|
||||
import { CartProvider } from "@/context/CartContext";
|
||||
import { usePathname } from "next/navigation";
|
||||
import SiteHeader from "@/components/layout/SiteHeader";
|
||||
@@ -28,6 +29,18 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
// The visibility:hidden pattern was causing click handling issues on hidden elements
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false} disableTransitionOnChange>
|
||||
{/*
|
||||
* MotionConfig caps every framer-motion animation in the tree:
|
||||
* - default transition is 0.2s easeOut (was usually 0.6-1s with custom cubic-bezier eases)
|
||||
* - `reducedMotion="user"` makes framer-motion automatically strip x/y/scale/rotate
|
||||
* from animations when the user has prefers-reduced-motion: reduce set, leaving
|
||||
* only opacity. This is the single most important lever for vestibular comfort —
|
||||
* it overrides the 71 `initial={{ y: 48 }}` / `initial={{ scale: 0 }}` reveals
|
||||
* across the public site without touching each file.
|
||||
* Combined with the prefers-reduced-motion block in globals.css, this means a user
|
||||
* with motion sensitivity gets the same content with zero positional movement.
|
||||
*/}
|
||||
<MotionConfig transition={{ duration: 0.2, ease: "easeOut" }} reducedMotion="user">
|
||||
<CartProvider>
|
||||
{!isBrandRoute && !isLandingPage && !isAuthPage && !isAdminRoute && !isStandalonePage && <SiteHeader />}
|
||||
{children}
|
||||
@@ -35,6 +48,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
<CartToast />
|
||||
<CartRestoredToast />
|
||||
</CartProvider>
|
||||
</MotionConfig>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -140,8 +140,8 @@ export function OnboardingFlow({ brandId: _brandId, userId: _userId, onComplete
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="bg-white rounded-2xl p-8 max-w-md text-center"
|
||||
>
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
@@ -173,11 +173,13 @@ export function OnboardingFlow({ brandId: _brandId, userId: _userId, onComplete
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="bg-white rounded-2xl shadow-2xl max-w-lg w-full overflow-hidden"
|
||||
>
|
||||
{/* Progress bar */}
|
||||
|
||||
@@ -29,11 +29,15 @@ interface CinematicShowcaseProps {
|
||||
brandName?: string;
|
||||
}
|
||||
|
||||
// Single product card with scroll-driven morphing
|
||||
// Single product card — no scroll-driven morphing.
|
||||
// The previous version scaled/rotated cards based on scroll position
|
||||
// (rotateY ±5°, scale 0.95→1.02, image parallax). All of that is gone —
|
||||
// cards now render statically and the active state is communicated by
|
||||
// opacity only.
|
||||
function MorphingProductCard({
|
||||
product,
|
||||
index,
|
||||
isActive,
|
||||
index: _index,
|
||||
isActive: _isActive,
|
||||
}: {
|
||||
product: Product;
|
||||
index: number;
|
||||
@@ -44,36 +48,22 @@ function MorphingProductCard({
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !cardRef.current) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
// Morphing effect based on scroll position
|
||||
gsap.to(cardRef.current, {
|
||||
scale: isActive ? 1.02 : 0.95,
|
||||
opacity: isActive ? 1 : 0.5,
|
||||
y: isActive ? 0 : index * 20,
|
||||
duration: 0.6,
|
||||
ease: "power3.out",
|
||||
});
|
||||
|
||||
// Subtle rotation for depth
|
||||
gsap.to(cardRef.current, {
|
||||
rotateY: isActive ? 0 : (index % 2 === 0 ? -5 : 5),
|
||||
duration: 0.8,
|
||||
ease: "power2.out",
|
||||
});
|
||||
}, cardRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [isActive, index]);
|
||||
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
|
||||
// Intentionally a no-op now — the parent controls layout/active state
|
||||
// via the wrapper, not the card. Keeping the useEffect shell so we
|
||||
// can reintroduce small motion later if needed.
|
||||
return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
void cardRef; void imageRef;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className="relative flex-shrink-0 w-[85vw] sm:w-[70vw] md:w-[45vw] lg:w-[35vw]"
|
||||
style={{ perspective: "1000px" }}
|
||||
>
|
||||
<div className="relative aspect-[4/5] rounded-3xl overflow-hidden bg-gradient-to-br from-stone-900 to-stone-950">
|
||||
{/* Image with parallax */}
|
||||
{/* Image — no parallax, no scale change on active. */}
|
||||
<div ref={imageRef} className="absolute inset-0">
|
||||
{product.imageUrl && (
|
||||
<Image
|
||||
@@ -81,10 +71,6 @@ function MorphingProductCard({
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
style={{
|
||||
transform: isActive ? "scale(1.05)" : "scale(1)",
|
||||
transition: "transform 0.6s ease-out",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Gradient overlay */}
|
||||
@@ -162,8 +148,14 @@ export default function CinematicShowcase({
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !containerRef.current) return;
|
||||
|
||||
const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
||||
if (prefersReducedMotion) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
// Scroll-driven product switching
|
||||
// Scroll-driven product switching — kept (it's the structure of this
|
||||
// section), but no positional movement of the cards themselves. The
|
||||
// active state is communicated by opacity only, computed in the
|
||||
// onUpdate and used to setActiveIndex.
|
||||
const productSwitchTrigger = ScrollTrigger.create({
|
||||
trigger: containerRef.current,
|
||||
start: "top top",
|
||||
@@ -172,7 +164,6 @@ export default function CinematicShowcase({
|
||||
onUpdate: (self) => {
|
||||
setScrollProgress(self.progress);
|
||||
|
||||
// Calculate which product should be active
|
||||
const progress = self.progress;
|
||||
const productCount = products.length;
|
||||
const segmentSize = 1 / productCount;
|
||||
@@ -187,33 +178,24 @@ export default function CinematicShowcase({
|
||||
},
|
||||
});
|
||||
|
||||
// Parallax for background text
|
||||
gsap.to(".showcase-headline", {
|
||||
y: -100,
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: containerRef.current,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 1,
|
||||
},
|
||||
});
|
||||
// Parallax for background text — removed. The HARVEST headline now
|
||||
// sits at a fixed Y so the eye doesn't chase it as the user scrolls.
|
||||
|
||||
// Scale reveal for cards
|
||||
// Scale reveal for cards — replaced with a calm opacity-only fade.
|
||||
// No more back.out(1.4) overshoot, no 100px slide, no 0.9→1 scale.
|
||||
gsap.fromTo(
|
||||
".product-card-wrapper",
|
||||
{ opacity: 0, y: 100, scale: 0.9 },
|
||||
{ opacity: 0, y: 8 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
duration: 0.8,
|
||||
stagger: 0.15,
|
||||
ease: "back.out(1.4)",
|
||||
duration: 0.32,
|
||||
stagger: 0.04,
|
||||
ease: "power1.out",
|
||||
scrollTrigger: {
|
||||
trigger: containerRef.current,
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none reverse",
|
||||
start: "top 85%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -232,34 +214,36 @@ export default function CinematicShowcase({
|
||||
>
|
||||
{/* Sticky container */}
|
||||
<div className="sticky top-0 h-screen overflow-hidden">
|
||||
{/* Background headline with parallax */}
|
||||
{/* Background headline — fixed position, no scroll parallax. */}
|
||||
<div className="showcase-headline absolute inset-0 flex items-center justify-center pointer-events-none overflow-hidden">
|
||||
<h2
|
||||
className="text-[25vw] font-black text-white/5 leading-none select-none whitespace-nowrap"
|
||||
style={{ transform: "translateY(100px)" }}
|
||||
>
|
||||
HARVEST
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Progress dots */}
|
||||
{/* Progress dots — opacity + color only, no scale. */}
|
||||
<div className="absolute right-8 md:right-12 top-1/2 -translate-y-1/2 z-20 flex flex-col gap-3">
|
||||
{products.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-2 h-2 rounded-full transition-all duration-500 ${
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
i === activeIndex
|
||||
? "bg-emerald-500 scale-150"
|
||||
: "bg-white/30 scale-100"
|
||||
? "bg-emerald-500"
|
||||
: "bg-white/30"
|
||||
}`}
|
||||
style={{ transition: "background-color 200ms ease-out" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{/* Progress bar — width updates as scroll changes. No transition
|
||||
* (the scroll itself is the animation; an additional transition
|
||||
* makes the bar lag behind the user's scroll position). */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/10">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-600 to-amber-500 transition-all duration-300"
|
||||
className="h-full bg-gradient-to-r from-emerald-600 to-amber-500"
|
||||
style={{ width: `${scrollProgress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -278,23 +262,19 @@ export default function CinematicShowcase({
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Product showcase */}
|
||||
{/* Product showcase — cards sit in a flex row, all visible. The
|
||||
* active state is opacity only (no scale, no translate, no big
|
||||
* cubic-bezier). Switching is communicated by which card is
|
||||
* bright. */}
|
||||
<div className="relative w-full overflow-visible">
|
||||
<div
|
||||
className="flex gap-6 px-8 md:px-16"
|
||||
style={{
|
||||
transform: `translateX(${-activeIndex * 15}vw)`,
|
||||
transition: "transform 0.6s cubic-bezier(0.22, 0.61, 0.36, 1)",
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-6 px-8 md:px-16">
|
||||
{products.map((product, i) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="product-card-wrapper"
|
||||
style={{
|
||||
opacity: i === activeIndex ? 1 : 0.3,
|
||||
transform: `scale(${i === activeIndex ? 1 : 0.85})`,
|
||||
transition: "all 0.6s cubic-bezier(0.22, 0.61, 0.36, 1)",
|
||||
opacity: i === activeIndex ? 1 : 0.4,
|
||||
transition: "opacity 200ms ease-out",
|
||||
}}
|
||||
>
|
||||
<MorphingProductCard
|
||||
|
||||
@@ -51,15 +51,26 @@ export default function TuxedoVideoHero({
|
||||
}, []);
|
||||
|
||||
// ─── GSAP SCROLL ANIMATIONS ────────────────────────────────────────────────
|
||||
// Motion pass: the original used a 1.15 video scale, an 80px y-shift on the
|
||||
// hero content, a parallax -100/+80 movement on decorative orbs, and a 1s
|
||||
// 40px slide-up entrance with power3.out easing. All of those are strong
|
||||
// vestibular triggers. The motion here is now:
|
||||
// - tiny entrance slide (8px) on first paint, no scale, 320ms, linear-ish ease
|
||||
// - video stays static on scroll (no parallax)
|
||||
// - decorative orbs float on a 10s CSS keyframe (gentle, opt-in to reduced motion)
|
||||
// - scroll indicator just fades on scroll, no positional shift
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !sectionRef.current) return;
|
||||
|
||||
// Respect reduced motion preference
|
||||
// Respect reduced motion preference — render everything visible immediately.
|
||||
const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
||||
if (prefersReducedMotion) return;
|
||||
if (prefersReducedMotion) {
|
||||
setIsVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
// Scroll progress tracking
|
||||
// Scroll progress tracking (the bar at the top of the page).
|
||||
ScrollTrigger.create({
|
||||
trigger: sectionRef.current,
|
||||
start: "top top",
|
||||
@@ -70,12 +81,13 @@ export default function TuxedoVideoHero({
|
||||
},
|
||||
});
|
||||
|
||||
// Hero content pin - fade out on scroll
|
||||
// Hero content — gentle fade on scroll, NO positional shift.
|
||||
// (Previously: y: -80. Multi-axis scroll-driven motion is the worst
|
||||
// trigger for motion sickness. The content just dissolves as you scroll.)
|
||||
if (contentRef.current) {
|
||||
gsap.to(contentRef.current, {
|
||||
y: -80,
|
||||
opacity: 0,
|
||||
ease: "power2.inOut",
|
||||
ease: "power1.out",
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: "top top",
|
||||
@@ -85,59 +97,37 @@ export default function TuxedoVideoHero({
|
||||
});
|
||||
}
|
||||
|
||||
// Video scale + parallax
|
||||
if (videoRef.current) {
|
||||
gsap.to(videoRef.current, {
|
||||
scale: 1.15,
|
||||
y: 100,
|
||||
ease: "power2.inOut",
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: "top top",
|
||||
end: "50% top",
|
||||
scrub: 1.5,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Video — no scale, no parallax. Just a static background.
|
||||
// (Previously: scale 1.15 + y 100. The scale made the video breathe as
|
||||
// the user scrolled, which was cinematic but disorienting.)
|
||||
|
||||
// Staggered entrance for content elements
|
||||
// Staggered entrance for content elements — small Y, no scale, light ease.
|
||||
const contentElements = gsap.utils.toArray<Element>(".hero-reveal");
|
||||
contentElements.forEach((el, i) => {
|
||||
contentElements.forEach((el) => {
|
||||
gsap.fromTo(
|
||||
el,
|
||||
{ opacity: 0, y: 40, scale: 0.95 },
|
||||
{ opacity: 0, y: 8 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
duration: 1,
|
||||
ease: "power3.out",
|
||||
duration: 0.32,
|
||||
ease: "power1.out",
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 90%",
|
||||
toggleActions: "play none none reverse",
|
||||
start: "top 95%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Parallax floating elements
|
||||
const floatElements = gsap.utils.toArray<Element>(".parallax-float");
|
||||
floatElements.forEach((el, idx) => {
|
||||
gsap.to(el, {
|
||||
y: -80 - idx * 20,
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top bottom",
|
||||
end: "bottom top",
|
||||
scrub: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
// Parallax floating elements — disabled. The .parallax-float class is
|
||||
// kept in the markup so the CSS keyframe animation (`float-slow` /
|
||||
// `float-delayed`) still runs as a gentle ambient pulse, but the
|
||||
// scroll-driven Y shift is gone.
|
||||
|
||||
// Scroll indicator bounce on scroll
|
||||
// Scroll indicator — just fades, no bounce, no positional shift.
|
||||
gsap.to(".scroll-indicator", {
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
@@ -164,13 +154,12 @@ export default function TuxedoVideoHero({
|
||||
return (
|
||||
<>
|
||||
{/* ─── SCROLL PROGRESS BAR ──────────────────────────────────────────── */}
|
||||
<div className="fixed top-0 left-0 right-0 h-1 z-[1000] bg-black/20">
|
||||
<div className="fixed top-0 left-0 right-0 h-1 z-[1000] bg-black/20 scroll-progress-bar">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-400"
|
||||
style={{
|
||||
transform: `scaleX(${scrollProgress})`,
|
||||
transformOrigin: "left",
|
||||
transition: "transform 0.1s ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -250,12 +239,13 @@ export default function TuxedoVideoHero({
|
||||
className="relative z-10 mx-auto w-full max-w-6xl px-6 pb-32 pt-32 flex flex-col justify-end"
|
||||
>
|
||||
<div className="max-w-3xl">
|
||||
{/* Logo with glow effect */}
|
||||
{/* Logo with glow effect — calmer entrance. Was a 1s scale 0.9→1
|
||||
* with a custom ease. Now a 320ms opacity-only fade. */}
|
||||
<motion.div
|
||||
className="hero-reveal mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={isVisible ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 1, delay: 0.1, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={isVisible ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{ duration: 0.32, delay: 0.05, ease: "easeOut" }}
|
||||
>
|
||||
<div className="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" />
|
||||
{logoSrc ? (
|
||||
@@ -318,9 +308,9 @@ export default function TuxedoVideoHero({
|
||||
background: "linear-gradient(135deg, #059669 0%, #10b981 100%)",
|
||||
boxShadow: "0 8px 32px rgba(16, 185, 129, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
}}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/* whileHover / whileTap dropped — the gradient background
|
||||
* already shifts on hover, and the SVG arrow inside the
|
||||
* button still nudges right on hover. No additional scale. */
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-3">
|
||||
<span>{primaryButton}</span>
|
||||
@@ -449,25 +439,29 @@ export default function TuxedoVideoHero({
|
||||
|
||||
{/* ─── GLOBAL ANIMATIONS ──────────────────────────────────────────────── */}
|
||||
<style jsx>{`
|
||||
/* Ambient orb drift — gentle, slow, predictable.
|
||||
* Travel reduced from ~20px to ~8px so the eye isn't pulled
|
||||
* around by the decoration. */
|
||||
@keyframes float-slow {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
33% { transform: translate(15px, -20px); }
|
||||
66% { transform: translate(-10px, 10px); }
|
||||
50% { transform: translate(6px, -8px); }
|
||||
}
|
||||
|
||||
@keyframes float-delayed {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
33% { transform: translate(-20px, 15px); }
|
||||
66% { transform: translate(10px, -10px); }
|
||||
50% { transform: translate(-8px, 6px); }
|
||||
}
|
||||
|
||||
/* Scroll-indicator dot — was a 1.5s ease-in-out bounce. Now a
|
||||
* slower 2.4s linear slide so it reads as a steady hint instead
|
||||
* of a constant bounce. */
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(6px); }
|
||||
50% { transform: translateY(3px); }
|
||||
}
|
||||
|
||||
.animate-bounce {
|
||||
animation: bounce 1.5s ease-in-out infinite;
|
||||
animation: bounce 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.parallax-float {
|
||||
|
||||
@@ -17,10 +17,12 @@ import type { ReactNode } from "react";
|
||||
* the page header) for shared-element morphing. Use the default
|
||||
* `"page-content"` for plain crossfades.
|
||||
*
|
||||
* The default `update="default"` plus a CSS `::view-transition-old/new`
|
||||
* rule (see globals.css) gives us a 220ms crossfade with a tiny
|
||||
* downward shift on the incoming page — fast enough to feel like a
|
||||
* single app, slow enough to soften the cut between routes.
|
||||
* Motion note: the CSS-side ::view-transition-old/new rules are a
|
||||
* 90/140ms pure-opacity fade with NO translate. Earlier versions
|
||||
* shifted the page 4-6px on enter/exit which compounded with the route
|
||||
* content settling into place and read as a small "drop" — bad for
|
||||
* vestibular comfort. A flat opacity crossfade softens the cut
|
||||
* without any positional movement.
|
||||
*
|
||||
* Usage:
|
||||
* <main>
|
||||
|
||||
@@ -10,6 +10,15 @@ if (typeof window !== "undefined") {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
}
|
||||
|
||||
// Helper: bail out entirely if the user has asked for reduced motion.
|
||||
// We check this at the top of every effect so that the motion pass is
|
||||
// JS-side as well as CSS-side (CSS can only do so much — scroll-triggered
|
||||
// transforms come from GSAP/JS, so we have to opt out here too).
|
||||
function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches === true;
|
||||
}
|
||||
|
||||
interface StickyScrollSectionProps {
|
||||
children: React.ReactNode;
|
||||
height?: string;
|
||||
@@ -26,6 +35,11 @@ export default function StickyScrollSection({
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !containerRef.current) return;
|
||||
if (prefersReducedMotion()) {
|
||||
// With reduced motion, just show the content normally — no pinning.
|
||||
setIsStuck(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const trigger = ScrollTrigger.create({
|
||||
@@ -52,7 +66,7 @@ export default function StickyScrollSection({
|
||||
style={{ height }}
|
||||
>
|
||||
<div
|
||||
className={`sticky top-0 h-screen overflow-hidden transition-all duration-300 ${
|
||||
className={`sticky top-0 h-screen overflow-hidden ${
|
||||
isStuck ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
@@ -64,6 +78,8 @@ export default function StickyScrollSection({
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SCROLL-REVEAL WRAPPER - Layers content as user scrolls
|
||||
// Motion-reduced: max 12px translation (was 60px), 320ms (was 1s),
|
||||
// power1.out (was power3.out — no overshoot).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
interface ScrollRevealProps {
|
||||
children: React.ReactNode;
|
||||
@@ -87,25 +103,26 @@ export function ScrollReveal({
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !containerRef.current) return;
|
||||
if (prefersReducedMotion()) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const elements = containerRef.current?.querySelectorAll("[data-reveal]");
|
||||
|
||||
if (!elements?.length) {
|
||||
// Single child reveal
|
||||
// Single child reveal — small Y, short duration, light ease.
|
||||
gsap.fromTo(
|
||||
containerRef.current,
|
||||
{ opacity: 0, y: 60 },
|
||||
{ opacity: 0, y: 12 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 1,
|
||||
ease: "power3.out",
|
||||
duration: 0.32,
|
||||
ease: "power1.out",
|
||||
delay,
|
||||
scrollTrigger: {
|
||||
trigger: containerRef.current,
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none reverse",
|
||||
start: "top 88%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -117,19 +134,19 @@ export function ScrollReveal({
|
||||
|
||||
switch (effectiveDirection) {
|
||||
case "up":
|
||||
animProps.y = 60;
|
||||
animProps.y = 12;
|
||||
break;
|
||||
case "down":
|
||||
animProps.y = -60;
|
||||
animProps.y = -12;
|
||||
break;
|
||||
case "left":
|
||||
animProps.x = 60;
|
||||
animProps.x = 12;
|
||||
break;
|
||||
case "right":
|
||||
animProps.x = -60;
|
||||
animProps.x = -12;
|
||||
break;
|
||||
case "scale":
|
||||
animProps.scale = 0.9;
|
||||
// Drop the scale-on-reveal; pure opacity is enough.
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -140,14 +157,13 @@ export function ScrollReveal({
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
duration: 0.8,
|
||||
stagger: stagger ? 0.1 : 0,
|
||||
ease: "power3.out",
|
||||
duration: 0.32,
|
||||
stagger: stagger ? 0.05 : 0,
|
||||
ease: "power1.out",
|
||||
scrollTrigger: {
|
||||
trigger: containerRef.current,
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none reverse",
|
||||
start: "top 88%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -165,38 +181,44 @@ export function ScrollReveal({
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PARALLAX LAYER - Depth effect on scroll
|
||||
// Motion-reduced: parallax is the #1 vestibular trigger. We still render
|
||||
// the children (so layout doesn't break) but skip the scroll-driven Y
|
||||
// shift entirely. If callers need depth they can use color/opacity.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
interface ParallaxLayerProps {
|
||||
children: React.ReactNode;
|
||||
speed?: number; // 0-1, higher = more movement
|
||||
speed?: number; // kept for API compatibility — ignored when reduced motion is on
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function ParallaxLayer({
|
||||
children,
|
||||
speed = 0.5,
|
||||
speed: _speed = 0.5,
|
||||
className = "",
|
||||
style,
|
||||
}: ParallaxLayerProps) {
|
||||
const layerRef = useRef<HTMLDivElement>(null);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
if (typeof window === "undefined" || !layerRef.current) return;
|
||||
if (prefersReducedMotion()) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClient || !layerRef.current) return;
|
||||
// Even with motion enabled, parallax is the worst offender. Cap the
|
||||
// maximum travel at 24px (was 150px) and only if explicitly opted in
|
||||
// via a data attribute — otherwise skip the scroll effect.
|
||||
const node = layerRef.current;
|
||||
const enabled = node.dataset.parallax === "true";
|
||||
if (!enabled) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const moveDistance = speed * 150; // max 150px movement
|
||||
const moveDistance = 24; // hard cap, ignores speed prop for safety
|
||||
|
||||
gsap.to(layerRef.current, {
|
||||
gsap.to(node, {
|
||||
y: -moveDistance,
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: layerRef.current,
|
||||
trigger: node,
|
||||
start: "top bottom",
|
||||
end: "bottom top",
|
||||
scrub: 1,
|
||||
@@ -205,11 +227,7 @@ export function ParallaxLayer({
|
||||
}, layerRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [isClient, speed]);
|
||||
|
||||
if (!isClient) {
|
||||
return <div className={className} style={style}>{children}</div>;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={layerRef} className={className} style={style}>
|
||||
@@ -220,6 +238,8 @@ export function ParallaxLayer({
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PROGRESS INDICATOR - Shows scroll progress through section
|
||||
// Motion-reduced: no scrub (so the bar doesn't continuously chase the scroll
|
||||
// position); renders at 0 instead.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
interface ProgressIndicatorProps {
|
||||
className?: string;
|
||||
@@ -235,6 +255,7 @@ export function ProgressIndicator({
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !containerRef.current) return;
|
||||
if (prefersReducedMotion()) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
ScrollTrigger.create({
|
||||
@@ -258,7 +279,7 @@ export function ProgressIndicator({
|
||||
className="w-1 h-64 bg-white/10 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-full transition-all duration-100"
|
||||
className="w-full rounded-full"
|
||||
style={{
|
||||
height: `${progress * 100}%`,
|
||||
background: color,
|
||||
@@ -272,7 +293,10 @@ export function ProgressIndicator({
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FADE ON SCROLL - Elements that fade/scale as user scrolls
|
||||
// FADE ON SCROLL - Elements that fade as user scrolls into view
|
||||
// Motion-reduced: capped at 16px translation (was 100px), 320ms (was 1s),
|
||||
// power1.out. toggleActions "play none none none" so the element doesn't
|
||||
// re-animate when you scroll back up (less positional movement overall).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
interface FadeOnScrollProps {
|
||||
children: React.ReactNode;
|
||||
@@ -289,33 +313,38 @@ export function FadeOnScroll({
|
||||
className = "",
|
||||
from = "bottom",
|
||||
to,
|
||||
distance = 100,
|
||||
duration = 1,
|
||||
distance = 16,
|
||||
duration = 0.32,
|
||||
delay = 0,
|
||||
}: FadeOnScrollProps) {
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !elementRef.current) return;
|
||||
if (prefersReducedMotion()) return;
|
||||
|
||||
// Cap distance regardless of caller — protects against accidental
|
||||
// 100+ px translations in the future.
|
||||
const cappedDistance = Math.min(distance, 16);
|
||||
const cappedDuration = Math.min(duration, 0.4);
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const fromProps: gsap.TweenVars = { opacity: 0 };
|
||||
let fromY = 0;
|
||||
let fromX = 0;
|
||||
|
||||
// Use 'from' direction for the starting position
|
||||
switch (from) {
|
||||
case "top":
|
||||
fromY = distance;
|
||||
fromY = cappedDistance;
|
||||
break;
|
||||
case "bottom":
|
||||
fromY = -distance;
|
||||
fromY = -cappedDistance;
|
||||
break;
|
||||
case "left":
|
||||
fromX = distance;
|
||||
fromX = cappedDistance;
|
||||
break;
|
||||
case "right":
|
||||
fromX = -distance;
|
||||
fromX = -cappedDistance;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -329,13 +358,13 @@ export function FadeOnScroll({
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
duration,
|
||||
ease: "power3.out",
|
||||
duration: cappedDuration,
|
||||
ease: "power1.out",
|
||||
delay,
|
||||
scrollTrigger: {
|
||||
trigger: elementRef.current,
|
||||
start: "top 85%",
|
||||
toggleActions: "play none none reverse",
|
||||
start: "top 90%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user