Skip to content

Motion

How Fruit Plug moves. If a pixel is translating, scaling, or fading, it is because it's doing a job — not because motion is "free". Our reference points are the Apple product pages and the Stripe landing: premium, restrained, every animation reinforces the content.

Principles

  1. Every animation has a job. If you can't name the intent in one sentence ("draws attention to the primary CTA", "confirms the route changed", "gives the stats a sense of magnitude"), cut it.
  2. Restrained over playful. No bounce springs, no floating-fruit chaos. A 4 px rise and a 600 ms fade is usually enough.
  3. Performance-first. Pure CSS wherever possible; IntersectionObserver for scroll-trigger reveals; requestAnimationFrame only for the handful of places that need it (CountUp, MagneticButton). No animation library. Not framer-motion, not motion/react, not GSAP.
  4. Accessibility is non-negotiable. Every new animation MUST honour prefers-reduced-motion: reduce. The global killswitch in apps/web/app/globals.css is the single source of truth — register new keyframes there.
  5. Brand as motion. Colour shifts do the work of a bounce. Magenta for primary affordances, sticker amber for premium, leaf green for in-stock / seasonal. Button hovers are colour swaps, not pops.

Timing tokens

All tokens live in apps/web/lib/motion.ts. Durations in milliseconds; easings are cubic-bezier() strings.

Token Value Used for
durationMicro 200 ms hovers, tints, colour swaps
durationBase 600 ms scroll reveals, drawers
durationPage 200 ms route-change fade-in
durationCount 1500 ms CountUp stat numbers
easeOut cubic-bezier(0.22, 1, 0.36, 1) default — decelerates into place
easeOutExpo cubic-bezier(0.16, 1, 0.3, 1) CountUp — fast start, long settle
easeStandard cubic-bezier(0.4, 0, 0.2, 1) page transitions

Where motion shows up

Surface Motion
Hero headline Per-word stagger-reveal on first paint (60 ms between words)
Home section blocks Reveal on-scroll fade-up (16 px, 600 ms ease-out)
Box and Japanese-selection cards Staggered Reveal (50–60 ms per card), hover lift + glow
Stat numbers CountUp 0 → target over 1.5 s ease-out-expo
BuildYourOwn decorative icons Gentle fp-float bob, desynchronised via per-icon delays
fp-stripe dividers 20 s horizontal pan of the magenta gradient
Primary CTAs (desktop) MagneticButton — ≤6 px pull toward cursor + 3 % scale
Route changes app/template.tsx 200 ms fade + 6 px translate
Bottom tab bar Sliding magenta indicator under active tab (250 ms)
Atlas + Japanese cards 4 px lift + soft magenta glow on hover (.fp-card-lift)
Product / box card images 500 ms scale-up + magenta vignette on hover
PDP thumbnail strip Active thumb wears a sticker-amber outline (scale-up, 200 ms)
Watch-the-plug play glyph 1.2 s ease-in-out pulse, hover-only

Reduced-motion compliance

The @media (prefers-reduced-motion: reduce) block in globals.css disables every keyframe we ship. New animations must be added to that list. Client hooks (Reveal, CountUp, MagneticButton) check window.matchMedia("(prefers-reduced-motion: reduce)") at mount and either render the final state immediately (Reveal, CountUp) or skip attaching listeners entirely (MagneticButton).

Anti-patterns

  • ❌ Nested Reveal wrappers — they don't compose; pick the outermost surface and let children inherit.
  • ❌ Motion libraries — framer-motion would add ~40 KB gzipped for what we already achieve with CSS keyframes and 60 lines of rAF.
  • ❌ Long staggers (> 400 ms total). That's a loading screen, not a flourish.
  • ❌ Hover effects that stick on tap. MagneticButton checks (hover: hover) and (pointer: fine) and bails on touch.
  • ❌ Bouncy springs. Brand is premium, not kitsch.