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

Contents
- The philosophy: minimal GSAP, maximal CSS, per-section states
- The stack and the role of each piece
- The map of the 16 motions
- Step 1 — The foundation: Lenis, the entrance hook and the split at render
- Step 2 — The typographic nervous system: per-section word reveal (Motion 3)
- Step 3 — Quick wins in pure CSS: an underline that travels and a masked marquee
- Step 4 — The entrance frame: preloader with Lottie and the route brushstroke
- Step 5 — The navigation shell: a chameleon header and a cascading mobile nav
- Step 6 — The pattern in depth: an accordion with cascading words (Motion 13)
- Step 7 — The autoplay pair: testimonials with a ring and a quote that rewrites itself
- Step 8 — The liveliest section: autoplay process with curtains and live color (Motion 8)
- Step 9 — The site's only pin: cards with a 3D tilt (Motion 9)
- Step 10 — Content interactions: tabs with a clean exit and the live calculator
- Step 11 — The closing: brand shapes and a parallax footer
- The 15 traps that will bite you (read before coding)
- 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/--futuretrio 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, thetransitionlives 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
| Library | Role in the project | Why |
|---|---|---|
| Lenis | Smooth scroll with a custom expo easing + programmatic scrollTo | The audited site integrates Lenis with ScrollTrigger and exposes isScrolling in a global store |
| GSAP 3.13+ and @gsap/react | The 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 logo | The Lottie's own duration times the exit curtain |
| CSS transitions, keyframes and state classes | All the rest: word reveals, image curtains, accordion, marquee, progress rings | It 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
| # | Motion | Typical use | Trigger |
|---|---|---|---|
| 1 | Preloader with an animated logo + curtain | First load | Load |
| 2 | "Brushstroke" route transition | Every navigation | Navigation |
| 3 | Per-section word reveal | Titles and paragraphs, across the site | Inview |
| 4 | An underline that erases + micro-links | Text CTA links | Hover |
| 5 | Parameterized marquee with a mask | Logo/word bands | Loop |
| 6 | A header that adapts by theme + a veil | Global navigation | Scroll/Hover |
| 7 | Mobile nav with a cascade of tags | Mobile menu | Click |
| 8 | Autoplay process with curtains and live color | The "how it works" section | Inview/Auto/Click |
| 9 | Pinned cards with a 3D tilt | Statistics/manifesto | Scroll |
| 10 | Testimonials with a countdown ring | Social proof | Auto/Click |
| 11 | A quote that rewrites itself word by word | Inside Motion 10 | Slide change |
| 12 | Tabbed cards with an exit/entrance transition | Advantages/plans | Click |
| 13 | An accordion with cascading words | FAQ | Click |
| 14 | An interactive calculator with live results | Simulator/quote | Input |
| 15 | Decorative brand shapes per section | Backgrounds and visual support | State |
| 16 | Footer parallax + a smooth back to top | Global footer | Scroll/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.
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.
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
Motion 4 — An underline that erases + link micro-interactions
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.Step 11 — The closing: brand shapes and a parallax footer
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.
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.Motion 16 — Footer parallax + a smooth back to top
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.
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 rawuseEffect: the automaticcontext.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 withclearTimeout; 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: falsestacks,pinon its own fails to: the card accumulation of Motion 9 depends on pinning ALL of them at the same point with no spacers; andperspectiveon the parent is mandatory for therotateXto 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
transitionlives in the CSS and the value in the style; animate color through JS per frame in no case. grid-template-rows: 0fr → 1frfor animated height (the accordion): the child withmin-height: 0; overflow: hidden; measure pixels in no case.- The SVG ring:
stroke-dasharray= the real circumference (2πr of theviewBox, 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-imageon wide bands is reasonable, but avoid stacking it withbackdrop-filteron the same scroll axis. ScrollTrigger.refresh()after images and fonts load; pins and positional scrubs depend on the final layout.prefers-reduced-motionon 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.
Read next
Motion •
Motion Design for the Web: The Complete Guide
Scroll, text, images and video: the complete catalog of motion techniques for the web, with implementation in Next.js and the cases where each one pays off.
- motion
- scroll
The definitive guide — a Next.js site built around motion and scroll
The scroll foundation that, when missing, keeps the animations from working at all: Lenis, GSAP and Next.js wired in the right order and the mistakes to avoid.
- next.js
- lenis
Infra •
Documentation: deploying a Next.js application with GitHub + Hostinger
Every push becomes a live site with no hosting panel involved: connecting GitHub to Hostinger, the build settings that break and the checks after each deploy.
- deploy
- github


