motion: calm screen transitions to reduce motion sickness
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:
Tyler
2026-06-17 08:35:29 -06:00
parent 7047e086d6
commit 9fcc514045
7 changed files with 286 additions and 238 deletions
+50 -70
View File
@@ -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
+52 -58
View File
@@ -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 {