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
+76 -49
View File
@@ -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%;
+14
View File
@@ -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>
);
}
+6 -4
View File
@@ -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 */}
+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 {
@@ -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>
+75 -46
View File
@@ -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",
},
}
);