Skip to content
Zumkai

Premium motions in Next.js: the 16 patterns of the Beetogreen site, step by step

The per-section state architecture of the Beetogreen site, with minimal GSAP, maximal CSS and replay by key, in 16 motion patterns with ready-made prompts.

  • motion
  • gsap
The Beetogreen home page: the headline "Mobility that makes a difference" in lime green over an office video, with dark green organic shapes cutting through the scene
Contents
  1. The philosophy: minimal GSAP, maximal CSS, per-section states
  2. The stack and the role of each piece
  3. The map of the 16 motions
  4. Step 1 — The foundation: Lenis, the entrance hook and the split at render
  5. Step 2 — The typographic nervous system: per-section word reveal (Motion 3)
  6. Step 3 — Quick wins in pure CSS: an underline that travels and a masked marquee
  7. Step 4 — The entrance frame: preloader with Lottie and the route brushstroke
  8. Step 5 — The navigation shell: a chameleon header and a cascading mobile nav
  9. Step 6 — The pattern in depth: an accordion with cascading words (Motion 13)
  10. Step 7 — The autoplay pair: testimonials with a ring and a quote that rewrites itself
  11. Step 8 — The liveliest section: autoplay process with curtains and live color (Motion 8)
  12. Step 9 — The site's only pin: cards with a 3D tilt (Motion 9)
  13. Step 10 — Content interactions: tabs with a clean exit and the live calculator
  14. Step 11 — The closing: brand shapes and a parallax footer
  15. The 15 traps that will bite you (read before coding)
  16. Where to start today

The fourth reverse-engineering study in the series, and one more different answer to the same question. Plantica animated everything with continuous CSS variables; ANAI divided the work into three layers; Studio Modular packaged each effect into a module with gates. The site this time holds the leanest philosophy of them all, and the one that talks best to the React way of thinking.

The audited site is Beetogreen (beetogreen.com), the French leader in corporate bicycle mobility: "Mobility that makes a difference", more than 500 thousand eligible employees, 500+ companies served. In visual terms: electric lime green over forest green, giant organic shapes cutting through the hero video, and a route transition signed with a calligraphic brushstroke. Behind it, a Nuxt/Vue platform + a headless CMS where the animation JavaScript comes down to half a dozen surgical interventions, and all the rest is CSS fired by state classes. An audit of that site produced the 16 patterns below, translated into the Next.js ecosystem; texts, colors and names are placeholders. You take away the pattern, the technique and a prompt ready to paste into your AI agent.

As always, the order of the steps is the order of construction.

The philosophy: minimal GSAP, maximal CSS, per-section states

The Beetogreen animation contract fits in one sentence: the JS decides "which state the section is in"; the CSS decides "how each state looks". In practice:

  • GSAP appears in very few places: the preloader, the route transition and two scrubs (the 3D cards and the footer). That is all.
  • All the rest is CSS fired by state classes at the section level: --entered, --active, --past, --open. A single IntersectionObserver hook toggles the entrance; the autoplay logic toggles the rest.
  • The --active/--past/--future trio is what allows content swaps in continuous flow: the words of the old content rise and leave over the top, the ones of the new enter from below, through the same slot, with no fights.
  • The section colors come from the CMS and the animations respect them: the value lives in the inline style, the transition lives in the CSS. The section changes palette with no line of tween.
  • And the most React-idiomatic pattern in the series: replay by key. CSS keyframes re-run when the node remounts (key={n}), and that is how quotes rewrite themselves, autoplay rings restart and brand shapes re-enter. The key is the play button.

The stack and the role of each piece

LibraryRole in the projectWhy
LenisSmooth scroll with a custom expo easing + programmatic scrollToThe audited site integrates Lenis with ScrollTrigger and exposes isScrolling in a global store
GSAP 3.13+ and @gsap/reactThe preloader, the "brushstroke" route transition, two scrubs (3D cards and the footer)GSAP is 100% free, every plugin included, since the Webflow acquisition. Here it serves in a minimalist way
ScrollTrigger (a GSAP plugin)Pinning the statistics cards and the footer parallax (with clamp())The only two scroll-driven uses on the whole site
Lottie (@lottiefiles/dotlottie-react)The animated preloader logoThe Lottie's own duration times the exit curtain
CSS transitions, keyframes and state classesAll the rest: word reveals, image curtains, accordion, marquee, progress ringsIt runs on the compositor; the states are the only API

No carousel plugin: the testimonial slider is homemade (swapping by index + keyframe replay through key remounting, Motion 11).

The map of the 16 motions

#MotionTypical useTrigger
1Preloader with an animated logo + curtainFirst loadLoad
2"Brushstroke" route transitionEvery navigationNavigation
3Per-section word revealTitles and paragraphs, across the siteInview
4An underline that erases + micro-linksText CTA linksHover
5Parameterized marquee with a maskLogo/word bandsLoop
6A header that adapts by theme + a veilGlobal navigationScroll/Hover
7Mobile nav with a cascade of tagsMobile menuClick
8Autoplay process with curtains and live colorThe "how it works" sectionInview/Auto/Click
9Pinned cards with a 3D tiltStatistics/manifestoScroll
10Testimonials with a countdown ringSocial proofAuto/Click
11A quote that rewrites itself word by wordInside Motion 10Slide change
12Tabbed cards with an exit/entrance transitionAdvantages/plansClick
13An accordion with cascading wordsFAQClick
14An interactive calculator with live resultsSimulator/quoteInput
15Decorative brand shapes per sectionBackgrounds and visual supportState
16Footer parallax + a smooth back to topGlobal footerScroll/Click

Generic section reveals are always Motion 3: no other entrance system exists on the audited site, and the consistency comes from that.

Step 1 — The foundation: Lenis, the entrance hook and the split at render

The infrastructure in this guide holds two pieces that deserve special attention. useHasEntered, a once IntersectionObserver that returns a boolean, is the only reveal trigger on the whole site. And splitWords breaks the texts into spans during the render (a pure util that runs on the server): the markup reaches the browser with the slots ready, with no DOM measurement, no flash and no hydration mismatch.

txt
In a Next.js 14+ project with App Router, TypeScript and Tailwind, set up the
animation infrastructure:

1. Install: gsap @gsap/react lenis @lottiefiles/dotlottie-react
2. Create a client component "SmoothScrollProvider" that:
   - Instantiates Lenis with the easing of the audited site:
       new Lenis({
         easing: (t) => Math.min(1, 1.001 - Math.pow(2, -8 * t)),
         orientation: 'vertical',
         smoothWheel: true
       })
   - Syncs with ScrollTrigger using a named function:
       const update = (time: number) => lenis.raf(time * 1000);
       lenis.on('scroll', ScrollTrigger.update);
       gsap.ticker.add(update);
       gsap.ticker.lagSmoothing(0);
   - COMPLETE cleanup on unmount (in this order):
       gsap.ticker.remove(update);
       lenis.off('scroll', ScrollTrigger.update);
       lenis.destroy();
   - Exposes through Context (useLenis) the instance + an isScrolling
     state (from the Lenis scroll callback), the way the audited site does.
3. Register the plugins once in lib/gsap.ts:
   gsap.registerPlugin(ScrollTrigger, useGSAP); export gsap.
4. Create the hook "useHasEntered(ref, { threshold })": a once
   IntersectionObserver that returns hasEntered (boolean). It is the ONLY reveal
   trigger on the whole site: each section uses it to turn on its '--entered' class.
5. Create a util "splitWords(text)" (and "splitLinesAndWords") that breaks
   strings into arrays of words at RENDER time (server-friendly): the markup
   reaches the browser with the reveal spans ready, with no measurement.
6. A usePrefersReducedMotion() hook; components degrade when it is true.
7. Tokens in globals.css (the easings of the audited site):
   :root {
     --ease-reveal: cubic-bezier(0.77, 0, 0.175, 1);
     --ease-ui: cubic-bezier(0.645, 0.045, 0.355, 1);
     --ease-soft: cubic-bezier(0.32, 0.72, 0, 1);
     --dur-reveal: 1s;
   }
Deliver the complete files and the updated layout.tsx.

Step 2 — The typographic nervous system: per-section word reveal (Motion 3)

No Beetogreen text appears all at once: each word rises from inside an invisible slot, a fraction of a second after the previous one, in a firm, decelerating movement. And when content leaves the scene (a step change, a slide change), the words rise and disappear over the top edge, opening room for the new ones to rise from below. The whole site breathes to that beat, and close to every following motion consumes this one.

The architecture matters as much as the effect: the trigger is a class on the section (never per word), and the three states (future below, visible in place, past above) are what allows content swaps in continuous flow in Motions 8, 10 and 13.

txt
Create the word reveal system in Next.js:

1. A pure util "splitWords(text): string[]" and a component
   "RevealWords({ text, as, delay = 0, step = 0.04 })" that renders:
   <Tag aria-label={text}>
     {words.map((w, i) =>
       <span class="w" aria-hidden="true" key={i}>
         <span class="w-inner"
           style={{ '--d': `${delay + i * step}s` }}>{w}</span>
       </span>)}
   </Tag>
   (splitting at render = perfect SSR, zero measurement, zero flash)
2. CSS:
   .w { display: inline-block; overflow: hidden;
        vertical-align: bottom; margin-right: .1em;
        padding-bottom: .12em; }   /* room for descenders (g, p) */
   .w-inner { display: inline-block; transform: translateY(110%);
     transition: transform var(--dur-reveal) var(--ease-reveal);
     transition-delay: var(--d, 0s); }
   [data-entered="true"] .w-inner,
   .--active .w-inner { transform: translateY(0); }
   .--past .w-inner { transform: translateY(-110%); }
   (the "past" state leaves over the top: that is what allows content
   swaps in continuous flow in Motions 8, 10 and 13)
3. A per-section trigger: each section uses useHasEntered(ref,
   { threshold: 0.2 }) and writes data-entered on its own node. One
   IO per section, and per word in no case.
4. An "AnimatedParagraph" variant: the same pattern with a smaller step (0.01)
   for long paragraphs.
5. prefers-reduced-motion: .w-inner with no transition (the final state).

Step 3 — Quick wins in pure CSS: an underline that travels and a masked marquee

The text links come underlined already. On hover, the underline erases by draining to the right; on leaving, it comes back entering from the left. The line grows and shrinks in the same place in no case: it travels. It is the same transform-origin swap as Studio Modular, inverted: there the line drew itself on hover; here it erases. A detail of personality.

txt
Create the "u-underline-out" utilities and button states in Next.js:

1. The erasing underline:
   .u-underline-out { position: relative; display: inline-block;
     overflow: hidden; vertical-align: top; }
   .u-underline-out::after { content: ''; position: absolute;
     bottom: 0; left: 0; width: 100%; height: 1px;
     background: currentColor; transform: scaleX(1);
     transform-origin: left center;
     transition: transform .7s var(--ease-reveal); }
   .u-underline-out:hover::after { transform: scaleX(0);
     transform-origin: right center; }
   (at rest: a full line; on hover: it erases leaving to the right;
   on unhover: it redraws entering from the left)
2. A button with loading:
   .button[data-loading="true"] .button__label { opacity: 0; }
   .button__spinner { position: absolute; inset: 0; margin: auto;
     width: 1.2em; aspect-ratio: 1; border-radius: 50%;
     border: 2px solid currentColor;
     border-right-color: transparent;
     animation: spin 0.8s linear infinite; opacity: 0; }
   .button[data-loading="true"] .button__spinner { opacity: 1; }
   @keyframes spin { to { transform: rotate(1turn) } }
   aria-busy="true" alongside data-loading.
3. An optional badge on the button (counters/tags) as a positioned
   span, with no animation of its own.
4. Hovers under @media (hover: hover) alone; focus-visible with a ring.

Motion 5 — Parameterized marquee with a fade mask

A conveyor of logos slides along without pause, with two refinements that set it apart from the ordinary marquee: the edges dissolve into a fade (the logos are born and die with a smooth move at the sides, through mask-image), and each instance picks a speed and a direction of its own through CSS vars. A Server Component, zero JS.

txt
Create a "FadeMarquee" component in Next.js (a Server Component):

1. Props: duration ('30s'), direction ('normal' | 'reverse'),
   gap ('6rem'), itemHeight ('3.2rem'), tint ('none' | 'dark' |
   'light'), fadeEdges (true), children (the items).
2. Structure: div.marquee (overflow hidden, width 100%,
   style with the CSS vars) > ul.marquee__list (inline-flex, gap,
   white-space nowrap, will-change transform, class "mq") with the
   items rendered TWICE (aria-hidden on the second copy).
3. CSS:
   .mq { animation: marquee var(--marquee-duration) linear infinite;
         animation-direction: var(--marquee-direction); }
   @keyframes marquee { from { transform: translate(0) }
     to { transform: translate(-50%) } }
   .marquee--fade { mask-image: linear-gradient(90deg,
     transparent 0, #000 8%, #000 92%, transparent);
     -webkit-mask-image: /* the same */; }
   .marquee--tint-dark .logo { filter: brightness(0); }
   .marquee--tint-light .logo { filter: brightness(0) invert(1); }
4. The -50% math demands the two identical copies; the gap goes
   inside the copy (padding-right on the last item or a flex gap with the
   copy starting aligned).
5. An optional pause on hover; prefers-reduced-motion: animation none
   (a static band with overflow-x auto so it stays explorable).

Step 4 — The entrance frame: preloader with Lottie and the route brushstroke

Motion 1 — Preloader with an animated logo + curtain

The screen opens on a panel of solid color where the logo draws itself (Lottie). Before the animation even ends, the panel rises like a stage curtain, revealing the finished site behind it. The engineering detail: the curtain timing is computed from the Lottie's own duration, so nothing is a guess. And the scroll gets released halfway through the curtain rather than at the end.

txt
Create a client component "Preloader" in Next.js:

1. A fixed inset-0 z-[60] panel with a solid background (a token) and the
   <DotLottieReact> of the logo centered (autoplay, no loop),
   at opacity 0 to begin with.
2. Flow (useGSAP + useLenis):
   - onMounted: lenis.stop();
   - once the player is ready: gsap.to(lottieWrap,
     { opacity: 1, duration: 0.1, ease: 'power2.out' });
     const dur = player.getDuration();      // seconds
     schedule the curtain at dur * WIPE_RATIO (a const, 0.6 for example);
     fallback: if the duration fails to arrive, fire it at once.
   - The curtain: tl = gsap.timeline({ delay });
     tl.to(panel, { clipPath: 'inset(0 0 100% 0)', duration: 1.2,
       ease: 'expo.inOut' });
     tl.call(() => { lenis.start(); onPreloaderDone(); }, [], 0.5);
     // it releases the scroll and the reveals MIDWAY through the curtain, rather than at the end
     tl.call(() => removePanel());
3. onPreloaderDone() is the signal the hero (Motion 3) waits for to
   turn on '--entered' in the first fold.
4. Run it once per session (sessionStorage); on other visits: no
   panel, an immediate signal.
5. prefers-reduced-motion: a panel with a quick fade, no Lottie.

Motion 2 — "Brushstroke" route transition

The site's signature. On clicking a link, a brushstroke crosses the screen: an organic stroke starts thin, runs the path of a drawn gesture and thickens until it becomes a blot that covers everything. The page swaps behind the ink; the stroke then comes undone in reverse. The ink color can change with the destination: a transition with calligraphy rather than a generic fade.

The mechanics come down to a single SVG <path>: stroke-dashoffset draws the gesture while stroke-width thickens from 450 to 800, covering the screen. And the robustness rules are worth gold: Promises that resolve a single time, and the previous timeline always killed before the new one.

txt
Create the route transition system in Next.js:

1. A "TransitionOverlay" component (client, in the root layout):
   a div fixed inset-0 z-50 pointer-events-none visibility-hidden >
   svg (a viewBox covering the screen, scale 1.1, preserveAspectRatio
   "xMidYMid slice") > the gesture path (fill none, stroke currentColor,
   stroke-linecap round). Draw an S/diagonal path crossing
   the viewBox; expose setStrokeColor(color).
2. An imperative API (Context): prepare(), playOut(color), playIn():
   - prepare(): const len = path.getTotalLength();
     gsap.set(path, { strokeDasharray: len, strokeDashoffset: len,
       opacity: 0 }); gsap.set(svg, { scale: 1.1 });
   - playOut(color): a Promise that:
     sets the color; gsap.set(wrapper, { pointerEvents: 'auto',
       visibility: 'visible', opacity: 1 });
     tl = gsap.timeline({ defaults: { duration: .7,
       ease: 'power1.inOut' } });
     tl.to(path, { opacity: 1, duration: .05 });
     tl.to(path, { strokeDashoffset: 0,
       attr: { 'stroke-width': 450 } }, '<');
     tl.call(() => { gsap.set(path, { strokeDashoffset: 0,
       attr: { 'stroke-width': 800 } }); resolve(); }, [], '>-0.2');
     // the final jump to 800 guarantees full coverage at no cost
   - playIn(): the inverse Promise (defaults .9s):
     on completing, gsap.set(wrapper, { pointerEvents: 'none',
       visibility: 'hidden', opacity: 0 }) and a path reset
     (dashoffset = len, stroke-width 2, opacity 0).
   - ALWAYS kill the previous timeline (tl?.kill()) before creating
     another, and resolve the Promise a single time (a flag).
3. "TransitionLink": it intercepts the click → await playOut(destinationColor)
   → router.push → in the new route's template.tsx, a useEffect calls
   playIn() (and lenis.scrollTo(0, { immediate: true }) before it).
4. popstate: skip playOut; run a short playIn alone.
5. prefers-reduced-motion: replace it with a 0.3s fade on the wrapper.

Step 5 — The navigation shell: a chameleon header and a cascading mobile nav

Motion 6 — A header that adapts by theme + a dropdown veil

The header is a fixed capsule that changes skin along with the section: white over light backgrounds, dark green over dark sections, with no break. The architecture trick: the header consumes tokens alone (--nav-bg, --nav-text) and the variants swap the tokens alone, so the skin change is a class. And on opening a submenu, a veil darkens the rest of the page, focusing the attention.

txt
Create a client component "AdaptiveHeader" in Next.js:

1. A header fixed top-0 z-40 with a capsule container reading tokens:
   .nav { --nav-bg: #fff; --nav-text: var(--tk-ink); }
   .nav--dark { --nav-bg: var(--tk-ink); --nav-text: #fafafa; }
   .nav--accent { --nav-bg: var(--tk-accent); --nav-text: #fafafa; }
   .nav__container { background: var(--nav-bg); color: var(--nav-text);
     transition: background .35s var(--ease-soft),
                 color .35s var(--ease-soft); border-radius: 999px; }
2. A theme per section: sections mark data-nav-theme="dark|light|accent";
   an IO with rootMargin "-50% 0px -50% 0px" (the middle line) watches
   and sets the header variant according to the section under the top.
3. The desktop dropdown: on opening (hover/click with aria-expanded),
   render the panel inside the capsule and turn on the veil:
   .nav__overlay { position: fixed; inset: 0;
     background: rgb(0 0 0 / .3); opacity: 0; pointer-events: none;
     transition: opacity .35s var(--ease-soft); z-index: 0; }
   .nav__overlay--visible { opacity: 1; }
   A click on the veil closes the dropdown; Escape does the same.
4. The header container sits above the veil (z-index 1) and the page
   content below it: the veil focuses without blocking the menu itself.
5. A lang switcher as a compact dropdown with the same mechanics.

Motion 7 — Mobile nav with a cascade of tags

The mobile menu opens and the items arrive apart from each other: each link rises a step with a fade, one after another, like tags being hung in sequence. And one implementation detail teaches the site's central pattern: keyframes rather than transitions, because keyframes re-run when the overlay remounts, giving a clean replay on each opening.

txt
Create a client component "MobileNav" in Next.js:

1. A burger button (aria-expanded, aria-controls) + a fullscreen
   overlay (mounted conditionally for the keyframe replay).
2. CSS:
   @keyframes tag-reveal { from { opacity: 0;
     transform: translateY(50%); } to { opacity: 1;
     transform: translateY(0); } }
   .mnav__item { opacity: 0;
     animation: tag-reveal .45s var(--ease-ui) forwards;
     animation-delay: calc(.15s + var(--i) * .06s); }
   .mnav__social { /* the same keyframe, delays after the items */ }
   Pass --i through an inline style in the item map.
3. The overlay background with a fade/slide of its own (.35s) before the cascade.
4. Accessibility: inert on the closed overlay, Escape closes, focus
   managed (the first item on opening, the button on closing),
   lenis.stop()/start().
5. prefers-reduced-motion: no keyframes (items visible straight away).

Step 6 — The pattern in depth: an accordion with cascading words (Motion 13)

The FAQ looks simple, but it is where the "section state drives words" pattern proves itself in depth. The "+" icon becomes a "−" by retracting the vertical bar; the answer unfolds in height through grid-template-rows: 0fr → 1fr (with no pixel measured); and the refinement: after it opens and no sooner, the words of the answer rise in a cascade, Motion 3 fired by the item's --open class, with a courtesy delay of 0.3s.

txt
Create a client component "CascadeAccordion" in Next.js:

1. Items with a <button aria-expanded aria-controls> (the question in
   RevealWords) + an icon:
   .icon { position: relative; width: 2rem; height: 2rem; }
   .icon-line { position: absolute; top: 50%; left: 50%;
     background: currentColor;
     transition: transform .4s var(--ease-ui); }
   .icon-line--h { width: 100%; height: 1.5px;
     transform: translate(-50%, -50%); }
   .icon-line--v { height: 100%; width: 1.5px;
     transform: translate(-50%, -50%); }
   .item--open .icon-line--v { transform: translate(-50%, -50%)
     scaleY(0); }
2. The answer:
   .answer-wrap { display: grid; grid-template-rows: 0fr;
     transition: grid-template-rows .5s var(--ease-ui); }
   .item--open .answer-wrap { grid-template-rows: 1fr; }
   .answer-inner { min-height: 0; overflow: hidden; }
3. The post-opening cascade: the answer uses the Motion 3 spans with:
   .item--open .answer .w-inner { transform: translateY(0);
     transition-delay: calc(0.3s + var(--d, 0s)); }
   (closed, the inners go back to translateY(110%) with no delay)
4. One item open at a time (or multiple, through a prop); the button's native
   Enter/Space; 1px dividers between items.
5. prefers-reduced-motion: height with no transition and words straight away.

Step 7 — The autoplay pair: testimonials with a ring and a quote that rewrites itself

Motion 10 — Testimonials with a countdown ring

One quote at a time, in large letters. The "next" arrow carries a secret: a ring draws itself around it, completing the turn in the exact time of the autoplay, and when the circle closes, the slide swaps. Resting the mouse on the arrow freezes the ring (and the autoplay); leaving resumes it from zero.

The ring replay is the house pattern: the SVG carries key={cycleCount}, so each swap increments the key, the node remounts and the keyframe restarts. Simple, and proof against ghost states.

txt
Create a client component "RingTestimonials" in Next.js:

1. Data: items[] { quote, author, role, avatar? } + colors through props.
   States: index, cycleKey (a number), paused; INTERVAL = 6000.
2. Autoplay: schedule() = clearTimeout + cycleKey++ +
   setTimeout(next, INTERVAL); call schedule() when the section enters
   (useHasEntered threshold 0.3) and after each next()/prev() if
   !paused; a click on the arrows navigates and reschedules.
3. The ring on the next arrow:
   <button class="arrow arrow--next" onMouseEnter={pause}
     onMouseLeave={resume} onFocus={pause} onBlur={resume}>
     <svg key={cycleKey} class="ring"
       style={{ '--progress-duration': `${INTERVAL}ms` }}
       viewBox="0 0 50 50"><circle cx="25" cy="25" r="24"/></svg>
     <ArrowIcon/>
   </button>
   CSS: .ring circle { fill: none; stroke: currentColor;
     stroke-width: 1; stroke-dasharray: 151;    /* 2πr */
     stroke-dashoffset: 151;
     animation: ring-fill var(--progress-duration) linear forwards; }
   @keyframes ring-fill { to { stroke-dashoffset: 0 } }
   .arrow--paused .ring circle { animation-play-state: paused; }
   pause(): paused=true + clearTimeout; resume(): paused=false +
   schedule() (the ring restarts from zero through a new key).
4. A "01 / 05" counter (padStart) with aria-live="polite"; the quote and
   the author swap through Motion 11.
5. prefers-reduced-motion: no autoplay and no ring (manual navigation).

Motion 11 — A quote that rewrites itself word by word

When the testimonial changes, the new quote slides in from the side in no way: it rewrites itself in place, each word rising from its slot in a quick cascade, the quotation marks included as "words". It is replay by key in its purest use: key={index}, React remounts, the keyframes run from zero. No timeline, no animation state. The key is the play.

txt
Create a "RewriteText" component in Next.js:

1. Props: text, replayKey (the key that forces the remount), step
   (the delay between words, default 0.03), baseDelay (0.1).
2. Render:
   <p key={replayKey} aria-label={text} class="rewrite">
     {splitWords(`“${text}”`).map((w, i) =>
       <span class="w" aria-hidden="true">
         <span class="w-inner"
           style={{ animationDelay:
             `${baseDelay + i * step}s` }}>{w}</span></span>)}
   </p>
   (the typographic quotation marks enter as "words" and join the
   cascade, as on the audited site)
3. CSS (keyframes rather than a transition: it has to run on each remount):
   .rewrite .w { display: inline-block; overflow: hidden;
     padding-bottom: .12em; }
   .rewrite .w-inner { display: inline-block;
     transform: translateY(100%);
     animation: word-in .8s var(--ease-reveal) forwards; }
   @keyframes word-in { to { transform: translateY(0) } }
4. Use it for the quote, the author and the counter of Motion 10 (equal keys,
   a larger baseDelay on the secondary ones).
5. prefers-reduced-motion: animation none, transform none.

Step 8 — The liveliest section: autoplay process with curtains and live color (Motion 8)

The "how it works" section presents itself: every 4 seconds it advances a step. On the swap, four things happen in harmony: the new image rises covering the previous one like a curtain (a clip-path from bottom to top with a settling zoom), the old texts leave word by word over the top while the new ones enter from below (Motion 3), the progress line fills at the exact rhythm of the 4s, and, the master touch, the background and text color of the whole section change to the palette of that step, coming from the CMS. Clicking a step takes control: the autoplay stops for good and the line starts filling fast.

This motion is the site's thesis proved: colors in the style, transitions in the CSS, states driving everything, and the progress line swapping its transition-duration (4s linear under autoplay, 0.8s with easing on a click) through the presence of the --auto class alone.

txt
Create a client component "AutoProcess" in Next.js:

1. Data: steps[] { label, smallLabel, title, description, imageUrl,
   bgColor, textColor } (from the CMS). States: active (the index),
   entered (useHasEntered threshold 0.2), manual (boolean).
2. Autoplay: on entered, if !manual: a 4000ms timeout advances active
   (stopping at the last one); a click on a step sets manual=true, cancels
   the timeout and goes straight to the index. Clear the timeout on unmount.
3. A section with style { backgroundColor: activeStep.bgColor,
   color: activeStep.textColor } and
   transition: background-color .8s var(--ease-ui), color .8s.
   Classes: --entered and --auto (entered && !manual).
4. The step list: each item with a button (aria-current="step" on the
   active one), a label in RevealWords and the line:
   .step-line { height: 1px; background:
     color-mix(in srgb, currentColor 20%, transparent);
     overflow: hidden; }
   .step-line-fill { height: 100%; background: currentColor;
     transform: scaleX(0); transform-origin: left; }
   .step--active .step-line-fill { transform: scaleX(1);
     transition: transform .8s var(--ease-reveal); }
   .--auto .step--active .step-line-fill {
     transition: transform 4s linear; }   /* synced to the autoplay */
5. Visual: absolute layers stacked in the order of the steps:
   .layer { clip-path: inset(100% 0 0 0);
     transition: clip-path 1.2s var(--ease-reveal); }
   .layer--active, .layer--past { clip-path: inset(0 0 0 0); }
   .layer img { transform: scale(1.08); transition: transform 1.2s
     var(--ease-reveal); }
   .layer--active img { transform: scale(1); }
6. Text content per step with --active/--past/--future states
   feeding Motion 3 (the words leave over the top, enter from below).
7. Decorative shapes (Motion 15) per step: a layer with a .6s fade and
   a replay on activation.
8. prefers-reduced-motion: no autoplay (click navigation alone) and
   swaps through a plain fade.

Step 9 — The site's only pin: cards with a 3D tilt (Motion 9)

A background sticks to the screen and the statistics cards arrive one on top of another; meanwhile, the card underneath tips backward in perspective: it leans, turns a hair and shrinks, like a card the next one lays down on the table. Scrolling back stands the cards up again in reverse order.

The mechanical secret: pin: true, pinSpacing: false on every card, so they all pin at the same point and accumulate through natural z-index, with no spacers. And perspective on the parent is mandatory, or the rotateX exists in no visual form.

txt
Create a client component "TiltStack" in Next.js:

1. Structure: section.stats > div.stats__bg (100vh, the stage that
   pins) + N section.stats__card (each one 100vh, with central content
   holding a giant number or phrase). CSS: .stats { perspective: 1200px; }
   on the card wrappers (needed for the rotateX to appear).
2. useGSAP (store the triggers in an array for the cleanup):
   const cards = gsap.utils.toArray('.stats__card');
   const last = cards[cards.length - 1];
   triggers.push(ScrollTrigger.create({ trigger: bgRef.current,
     start: 'top top', endTrigger: last, end: 'top top',
     pin: true, pinSpacing: false }));
   cards.forEach((card, i) => {
     triggers.push(ScrollTrigger.create({ trigger: card,
       start: 'top top', endTrigger: last, end: 'top top',
       pin: true, pinSpacing: false }));
     if (i < cards.length - 1) {
       const next = cards[i + 1];
       triggers.push(ScrollTrigger.create({ trigger: next,
         start: 'top bottom', end: 'top top', scrub: 0.5,
         onUpdate: (self) => {
           const p = self.progress;
           gsap.set(card, { rotate: 4 * p, rotateX: 36 * p,
             scale: 1 - 0.2 * p });
         } }));
     }
   });
   Cleanup: triggers.forEach(t => t.kill()).
   Note: pinSpacing: false is what makes the cards ACCUMULATE (with no
   spacers); the total section height = N natural viewports.
3. The card numbers can use RevealWords (Motion 3) on entering.
4. Mobile: disable it (gsap.matchMedia >= 1024px) and fall back to a
   simple vertical list; reduced-motion does the same.
5. ScrollTrigger.refresh() after images/fonts (the pin depends on the
   final layout).

Step 10 — Content interactions: tabs with a clean exit and the live calculator

Motion 12 — Tabbed cards with an exit/entrance transition

On changing tabs, the current cards say goodbye together (a fade going down) and the new set takes over, with the texts rewriting themselves. The editorial rule: two visible sets fighting in no case, since AnimatePresence mode="wait" guarantees everything leaves and everything then enters. And reserve the height of the largest set, or the swap pumps the layout.

txt
Create a client component "TabbedCards" in Next.js:

1. Accessible tabs (role=tablist/tab/tabpanel, aria-selected,
   keyboard arrows). State: activeTab.
2. With Framer Motion (recommended):
   <AnimatePresence mode="wait">
     <motion.div key={activeTab} class="cards-grid"
       initial={{ opacity: 0, y: 10 }}
       animate={{ opacity: 1, y: 0 }}
       exit={{ opacity: 0, y: 10 }}
       transition={{ duration: 0.4,
         ease: [0.645, 0.045, 0.355, 1] }}>
       {cards.map(…)}
     </motion.div>
   </AnimatePresence>
   (mode="wait" guarantees: everything leaves, then everything enters)
3. Without Framer: key={activeTab} + an entrance keyframe
   (opacity 0/y 10 → 1/0, .4s var(--ease-ui)); the exit becomes a hard
   cut: acceptable, but record the downgrade.
4. Card titles with RevealWords (a cascading delay across the cards:
   --entry-delay = i * .08s).
5. Height: reserve a min-height of the largest set (measured on mount)
   so the swap avoids pumping the layout.

Motion 14 — An interactive calculator with live results

Three sliders, and no "calculate" button: dragging any of them recalculates everything live, so the giant result number changes at once, formatted in the local standard, and equivalence cards translate the number into tangible comparisons. The motion upgrade: cushion the number with a short GSAP counter (~0.4s), so the value "runs" to the new result in place of swapping hard.

txt
Create a client component "LiveCalculator" in Next.js:

1. Props: parameterizable formulas and factors (nothing hard-coded from the
   domain); 3 sliders (a range input styled with a custom track/thumb)
   with a label and the value beside it.
2. Calculation: a useMemo deriving the result and N equivalences from the three
   values; formatting with Number.prototype.toLocaleString(locale).
3. The live number: keep a ref { v: current } and, on each change of the
   result, gsap.to(ref, { v: newValue, duration: 0.4,
   ease: 'power2.out', onUpdate: () => el.textContent =
   format(ref.v) }); (a short count rather than a "slot machine")
4. Accessible sliders: a label + aria-valuetext with the unit; step and
   limits through props; updating on onInput (rather than onChange) for the
   genuinely live feel.
5. Equivalence cards with a micro-transition of opacity on change
   (a blinked class) and RevealWords on the section entrance.
6. prefers-reduced-motion: the number swaps directly, with no count.

Motion 15 — Decorative brand shapes per section

The organic shapes of the identity, the giant curves you see cutting through the Beetogreen hero, live behind the content, each one with a position, a size and a rotation of its own. They are anything but static: they swap with a fade when the section state changes (each process step carries its own) and re-run a subtle entrance on coming back to the scene, replay by key once more.

txt
Create a "BrandShape" component in Next.js:

1. Props: shape (a key in a registry of brand SVGs), position
   ({ top, left }), size ('60rem'), rotation (in degrees), color,
   centered (boolean), replayKey (optional).
2. Render: a div.shape (position absolute, pointer-events none,
   z-index 0, aria-hidden) with style { top, left, width: size,
   transform: `${centered ? 'translate(-50%,-50%)' : ''}
   rotate(${rotation}deg)` } > the SVG (fill/stroke currentColor,
   color from the prop).
3. Layers per state (used in Motion 8): one div.shape-layer per
   state, absolute inset-0, opacity 0, transition opacity .6s
   var(--ease-reveal); the active one gains --active { opacity: 1 } and the
   inner shape with key={replayKey} remounts to re-run a light
   entrance keyframe (scale .96 → 1 + a .6s fade).
4. Mobile: a scale factor (~0.6) applied to the size and recentering
   (top/left 50% + centered), through a prop or matchMedia on the parent.
5. The shapes are decorative at all times: they receive focus and events in no case.

The footer arrives in a reveal parallax: it starts 25% "behind" upward and lands in position at the end of the scroll. The detail that avoids bugs in production: clamp() on both trigger points, without which pages shorter than the path make the trigger start out "already begun" and the parallax jumps. And the "back to top" button takes the page on a smooth 1.5s flight through Lenis rather than a teleport.

txt
Create a client component "ParallaxFooter" in Next.js:

1. Structure: div.footer-wrap (the outer ref) > footer.footer
   (the inner ref) with columns, socials and credits.
2. useGSAP (gate: matchMedia excluding phones, reduced motion false):
   tween = gsap.from(innerRef.current, {
     yPercent: -25, ease: 'none',
     scrollTrigger: { trigger: wrapRef.current,
       start: 'clamp(top bottom)', end: 'clamp(bottom bottom)',
       scrub: true } });
   Cleanup: tween.scrollTrigger?.kill(); tween.kill();
   (clamp() on both points: essential for footers, it keeps the trigger
   from blowing up on pages shorter than the path)
3. Socials with a cascade keyframe (the same pattern as Motion 7,
   social-reveal: opacity 0 + translateY(50%) → visible, delays
   by index) fired by useHasEntered on the footer.
4. A "back to top" button:
   const toTop = () => lenis
     ? lenis.scrollTo(0, { duration: 1.5 })
     : window.scrollTo({ top: 0, behavior: 'smooth' });
5. Links with the underline from Motion 4.

The 15 traps that will bite you (read before coding)

  • "use client" is mandatory in every component that touches GSAP, Lenis or observers.
  • Always useGSAP({ scope }) in place of a raw useEffect: the automatic context.revert() kills orphan tweens and triggers when you navigate between routes.
  • The Lenis cleanup takes 3 steps: gsap.ticker.remove(update) + lenis.off('scroll', ...) + lenis.destroy().
  • Split words at render, rather than in the browser: breaking the text into spans during the render (a pure util) eliminates DOM measurement, flash and hydration mismatch; save SplitText for cases that demand a real LINE break.
  • Replay by key is a pattern, so use it with intent: CSS keyframes re-run when the node remounts (key={n}); it is how the audited site restarts quotes, rings and shapes. Transitions re-run in NO such way, so pick keyframe vs. transition by the lifecycle.
  • Autoplay timeouts have a single owner: schedule() always starts with clearTimeout; pausing cancels; resuming reschedules from zero; unmount cleans up. Two live timers = slides jumping.
  • Transition Promises resolve ONCE: keep the flag, kill the previous timeline (tl?.kill()) before creating another, and resolve on the error path too, since the navigation can get stuck behind the overlay in no case.
  • pinSpacing: false stacks, pin on its own fails to: the card accumulation of Motion 9 depends on pinning ALL of them at the same point with no spacers; and perspective on the parent is mandatory for the rotateX to show up on screen.
  • clamp() on footer triggers: without it, short pages make the trigger start out "already begun" and the parallax jumps.
  • Dynamic CMS colors through an inline style + a transition in the CSS: the section that changes color by state animates because the transition lives in the CSS and the value in the style; animate color through JS per frame in no case.
  • grid-template-rows: 0fr → 1fr for animated height (the accordion): the child with min-height: 0; overflow: hidden; measure pixels in no case.
  • The SVG ring: stroke-dasharray = the real circumference (2πr of the viewBox, rather than of the rendered size); getting that number wrong breaks the ring's "close" against the end of the autoplay.
  • A marquee mask costs GPU: mask-image on wide bands is reasonable, but avoid stacking it with backdrop-filter on the same scroll axis.
  • ScrollTrigger.refresh() after images and fonts load; pins and positional scrubs depend on the final layout.
  • prefers-reduced-motion on everything: no autoplay, no ring, no parallax, no tilt; content straight to the final state, since the audited site turns it off at the root rather than "softening" it.

Where to start today

The entry trio: Step 1 (the infrastructure, where useHasEntered and splitWords are half an hour that pays for the whole site), Step 2 (the word reveal, which is the nervous system; with it ready, each new section is born breathing) and Step 3 (underline and marquee, pure CSS, an immediate result).

And the series now adds up to four philosophies: the continuous variables of Plantica, the three layers of ANAI, the gated modules of Studio Modular, and the per-section states of Beetogreen, with replay by key as the most React-idiomatic trick of them all. Four sites, no magic library in common: the thing that repeats is the discipline. States are the only API, and the rest is the CSS doing what it knows how to do.