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¶
- 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.
- Restrained over playful. No bounce springs, no floating-fruit chaos. A 4 px rise and a 600 ms fade is usually enough.
- Performance-first. Pure CSS wherever possible; IntersectionObserver
for scroll-trigger reveals;
requestAnimationFrameonly for the handful of places that need it (CountUp, MagneticButton). No animation library. Notframer-motion, notmotion/react, not GSAP. - Accessibility is non-negotiable. Every new animation MUST honour
prefers-reduced-motion: reduce. The global killswitch inapps/web/app/globals.cssis the single source of truth — register new keyframes there. - 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
Revealwrappers — they don't compose; pick the outermost surface and let children inherit. - ❌ Motion libraries —
framer-motionwould 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.
MagneticButtonchecks(hover: hover) and (pointer: fine)and bails on touch. - ❌ Bouncy springs. Brand is premium, not kitsch.