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%;