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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user