Skip to content
Zumkai

Premium motions in Next.js: the 17 patterns of the InterGest Canada site, step by step

GSAP, Framer Motion and Embla on the same site without fighting: the 17 motion patterns of the InterGest Canada site in Next.js, each with a ready-made prompt.

  • motion
  • gsap
The InterGest Canada home page: the headline "Your Gateway to Canadian Expansion" in white over an aerial video of Toronto with the CN Tower
Contents
  1. The philosophy: each tool on its own turf
  2. The stack and the role of each piece
  3. The map of the 17 motions
  4. Step 1 — The foundation: animation infrastructure
  5. Step 2 — Quick wins: marquee, CTA buttons and cookie banner
  6. Step 3 — The calling card: a complete hero over video
  7. Step 4 — Series production: the four scroll reveals
  8. Step 5 — The rails: horizontal timeline and numbered carousel
  9. Step 6 — The most delicate: a pinned section with step swapping (Motion 8)
  10. Step 7 — The crown jewel: rotation through a frame sequence (Motion 13)
  11. Step 8 — The closing: form, languages and the overlay menu
  12. The 11 traps that will bite you (read before coding)
  13. Where to start today

The fifth reverse-engineering study in the series. After four authored architectures (Plantica, ANAI, Studio Modular and Beetogreen), this guide answers a different question: what does the classic kit of premium sites look like when someone executes it well? Which library does what, and where does the border between them run?

The audited site is InterGest Canada (intergestcanada.com), "Your Gateway to Canadian Expansion": the Canadian operation of the international InterGest network, which handles everything a foreign company needs to operate in Canada, from incorporation to day-to-day management. The site opens with an aerial flight over Toronto (the CN Tower cutting through the headline), giant white typography over video, and a corporate, polished motion language, built in Webflow, which makes the exercise of translating it to Next.js instructive in a special way. The 17 patterns below are agnostic about content: names, values and assets are placeholders. You take away the pattern, the technique and a prompt ready to paste into your AI agent.

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

The philosophy: each tool on its own turf

The four previous guides held radical theses (CSS-first, three layers, gates, states). This one teaches the most common arrangement, and the lesson lies in drawing the borders well:

  • GSAP is the backbone of the spectacle: reveals, hero timelines, split text, pin, scrub, counters, parallax and the frame sequence. Everything that counts as page choreography goes through it.
  • Framer Motion handles the UI lifecycle: the overlay menu, the cookie banner, form states, spring physics on the buttons. The reason is a single one and it deserves memorizing: AnimatePresence solves unmount animation, which is painful in raw GSAP. A component that enters AND leaves → Framer; scroll choreography → GSAP.
  • Embla Carousel for everything that slides on rails: the horizontal timeline and the numbered carousel. Headless, light, accessible.
  • Pure CSS where no JS justifies itself: the footer marquee runs on the compositor with no line of script.
  • Lenis is optional and declared as such: a choice about feel rather than a requirement of the motions.

The stack and the role of each piece

LibraryRole in the projectWhy
GSAP 3.13+ and @gsap/reactThe backbone: reveals, timelines, split text, pin/scrubGSAP is 100% free, every plugin included (SplitText, ScrollTrigger, Draggable), since the Webflow acquisition
ScrollTrigger (a GSAP plugin)Every animation fired or driven by scrollSection pinning, scrub, cascading reveals
LenisGlobal smooth scroll (optional)It gives the "buttery glide" typical of premium sites; it integrates natively with ScrollTrigger. A choice about feel rather than a requirement: if the project calls for native scroll, skip Lenis
Framer Motion (motion)UI micro-interactions: overlay menu, cookie banner, form states, button hoverAnimatePresence solves component mounting and unmounting, which is painful in raw GSAP. The current package goes by motion (Motion for React)
Embla CarouselThe horizontal timeline and the content carouselLight, headless, accessible, with an API for custom dots and arrows

The map of the 17 motions

#MotionTypical useTrigger
1Fullscreen overlay menuHeader (the burger)Click
2Cookie bannerGlobal (first visit)Load
3Hero title reveal (split text)HeroLoad
4Sequential entrance of the subtitle/CTAHeroLoad
5Looping background videoHeroAutomatic
6Navigable horizontal timelineThe history/steps sectionArrows/drag
7Cascading cards + hover zoomA grid of cards with photosScroll + hover
8Pinned section with step swappingThe process sectionScroll
9Animated numeric countersThe statistics gridScroll
10Text block revealArgument/advantage blocksScroll
11Carousel with numbered paginationThe services/features sectionClick/auto
12Multi-layer image parallaxAn editorial section with photosScroll
13Rotation through a frame sequenceA "fake" 3D object (globe, product)Auto/scroll
14Infinite marqueeFooter/decorative bandsAutomatic loop
15Animated form statesNewsletter/contactSubmit
16CTA button hoverGlobal buttonsHover
17Language selector + route fadeHeaderHover/click

Generic section reveals (headings and paragraphs rising with a fade on entering the viewport) carry no number of their own: reuse the pattern from Motions 7 and 10 (y + autoAlpha with a once ScrollTrigger).

Step 1 — The foundation: animation infrastructure

The house standard setup: Lenis synced to ScrollTrigger with the three-step cleanup (cause number 1 of leaks when forgotten), plugins registered once, and the prefers-reduced-motion hook from the start.

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

1. Install: gsap @gsap/react lenis motion embla-carousel-react
2. Create a client component "SmoothScrollProvider" that:
   - Instantiates Lenis with { lerp: 0.1, wheelMultiplier: 1, smoothWheel: true }
   - Syncs with ScrollTrigger using a named function (required
     for the cleanup):
       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);        // without it the ticker keeps
                                          // calling raf() on a destroyed
                                          // instance (leak)
       lenis.off('scroll', ScrollTrigger.update);
       lenis.destroy();
   - Expose the instance through React Context with a useLenis() hook, so
     other components can call lenis.stop() / lenis.start()
     (the Motion 1 overlay menu depends on it).
3. Register the plugins once in a lib/gsap.ts file:
   gsap.registerPlugin(ScrollTrigger, SplitText, useGSAP) and export gsap.
   Client components alone should import that file.
4. Wrap {children} in the root layout.tsx with <SmoothScrollProvider>.
5. Create a usePrefersReducedMotion() hook that reads the media query
   (prefers-reduced-motion: reduce); every animation component should
   skip or reduce animations when it is true.
Deliver the complete files and the updated layout.tsx.

Three simple components that establish the vocabulary, and they demonstrate the division of turf already: CSS for the marquee, Framer Motion for what is born and dies.

Motion 14 — Infinite marquee

A band of text sliding along without pause, like an airport arrivals board. Pure CSS, a Server Component, zero JS, plus the math of the invisible loop: the track holds two identical groups and animates translateX(0 → -50%); when the first copy leaves, the second sits right where it was.

txt
Create a "FooterMarquee" component in Next.js (a Server Component works,
since it needs no JS):

1. Structure:
   <div class="overflow-hidden whitespace-nowrap" aria-hidden="true">
     <div class="marquee-track inline-flex">
       <!-- GROUP A: the phrase repeated K times (8x for example, through Array(K).map) -->
       <span>YOUR PHRASE HERE&nbsp;·&nbsp;</span> ...
       <!-- GROUP B: an identical copy of group A -->
     </div>
   </div>
   The rule: the track holds TWO identical groups, each one with
   exactly 50% of the content. That is what makes the -50% math
   close perfectly.
2. CSS (globals.css or the tailwind config):
   .marquee-track { animation: marquee 28s linear infinite; }
   @keyframes marquee {
     from { transform: translateX(0); }
     to   { transform: translateX(-50%); }
   }
   will-change: transform on the track.
3. An optional pause on hover: .marquee:hover .marquee-track
   { animation-play-state: paused; }
4. @media (prefers-reduced-motion: reduce) { .marquee-track
   { animation: none; } }
5. Accessibility: aria-hidden on the decorative marquee and an accessible
   version of the text (sr-only) outside it.
6. Large typography (text-4xl+), a subtle color (outlined text with
   -webkit-text-stroke or low opacity, for example), a consistent gap between
   repetitions.

Motion 16 — Hover micro-interactions on the CTA buttons

On mouse hover, the button responds: the background slides up from inside (an absolute span revealed on hover), the little arrow shifts a few pixels pointing the way, and the click "sinks" the button with spring physics. And one integration detail worth the whole prompt: motion.create(Link), the right way to give whileTap to an internal link without losing the App Router prefetch.

txt
Create a reusable "CtaButton" component in Next.js:

1. Props: href, children, variant: 'solid' | 'outline', external?: boolean.
2. Base (Tailwind): inline-flex items-center gap-2 rounded-full px-7 py-3.5
   relative overflow-hidden group.
3. The "rising background" effect: a <span> absolute inset-0 in the hover color with
   translate-y-full transition-transform duration-500
   ease-[cubic-bezier(0.76,0,0.24,1)] group-hover:translate-y-0;
   the label sits in <span class="relative z-10"> and changes color with
   a synchronized transition-colors.
4. Arrow: a → icon with transition-transform duration-300
   group-hover:translate-x-1.5.
5. The animated root element:
   - INTERNAL routes: use next/link with Framer Motion:
     const MotionLink = motion.create(Link)  // motion(Link) in earlier
                                             // versions of the lib
     <MotionLink href={href} whileTap={{ scale: 0.97 }}
       transition={{ type:'spring', stiffness: 400, damping: 17 }} />
     (it preserves the App Router prefetch and client-side navigation)
   - EXTERNAL links: motion.a with target="_blank" rel="noopener".
6. Visible focus states (focus-visible:ring): remove an outline in no case
   without a replacement.

A second and a half after the site opens, the little box slides up from below with a spring; accepting or rejecting slides it back, and it returns in no case. The two Next.js traps the prompt handles already: localStorage exists in the browser alone (the component returns null until the effect runs, avoiding a hydration mismatch), and the policy link points at the internal route rather than at staging.

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

1. A useEffect checks localStorage.getItem('cookie-consent'); if absent,
   it sets visible=true after a 1500ms delay (a setTimeout with cleanup).
2. Render it with <AnimatePresence>: a motion.div fixed bottom-6 left-6,
   initial { y: 80, opacity: 0 }, animate { y: 0, opacity: 1 },
   exit { y: 80, opacity: 0 }, a spring transition
   { stiffness: 260, damping: 24 }.
3. "Accept" and "Reject" buttons: on click, they save
   'accepted'/'rejected' to localStorage and set visible=false
   (the exit animates the departure).
4. Render it on the SSR in no case (return null until the effect runs) to avoid
   a hydration mismatch.
5. Include a link to the project's own INTERNAL privacy policy route
   (/legal/privacy-policy, for example), and never a staging URL or an
   external domain.

Step 3 — The calling card: a complete hero over video

The InterGest hero is the classic trio executed with precision: a title in masked split text, secondary elements in a queue, and the Toronto flight running behind it all. Note the rule that ties Motions 3 and 4 together: the title animates once, inside the single timeline. Implementing the two apart from each other makes the title animate twice.

Motion 3 — Hero title reveal (masked split text)

Each word of the giant headline rises from inside an invisible mask, one a touch after the other, with the key words highlighted. The modern SplitText API (3.13+) does the heavy lifting: autoSplit waits for the fonts to load and re-splits if the layout changes.

txt
Create a client component "HeroTitle" in Next.js with GSAP:

1. Import gsap and SplitText from lib/gsap.ts (plugins registered already) and the
   useGSAP hook from @gsap/react.
2. An H1 with a ref, holding the text with <strong> on the highlighted words.
   Apply an inline visibility:hidden to the H1 to avoid FOUC (a flash of the text
   with no animation before the JS runs).
3. Inside useGSAP (scoped to the container), use the modern SplitText API
   (3.13+) with autoSplit, which waits for the fonts to load and re-splits
   on its own if the layout changes:
   SplitText.create(h1Ref.current, {
     type: 'lines,words',
     linesClass: 'line',
     mask: 'lines',          // creates automatic overflow:hidden wrappers
     autoSplit: true,        // awaits document.fonts.ready internally
     onSplit: (self) => {
       gsap.set(h1Ref.current, { visibility: 'visible' });
       return gsap.from(self.words, {
         yPercent: 110, duration: 0.9,
         ease: 'power4.out', stagger: 0.06, delay: 0.2
       });                   // returning the tween lets autoSplit
     }                       // manage the cleanup on its own
   });
   (The alternative without autoSplit: wrap the split in
   document.fonts.ready.then(() => { ... }) and call split.revert()
   in the cleanup. Split before the fonts load in no case, or
   the line breaks come out wrong.)
4. Under prefers-reduced-motion, remove the hidden with no animation.

Motion 4 — Sequential entrance of the subtitle and CTA

After the title, the secondary elements enter in a queue: the slogan, the paragraph, the button, each one fading up, with a negative overlap ('-=0.4') for fluidity. The eye gets guided from top to bottom, in the order the designer wants.

txt
Extend the HeroTitle component into a complete "Hero" section:

1. Structure it: h1 (the split from motion 3), p.slogan, p.description, a.cta, all
   with selection classes inside the useGSAP scope.
2. Inside the SplitText onSplit (or after fonts.ready), create ONE
   gsap.timeline({ defaults: { ease: 'power3.out' } }):
   - tl.from(split.words, { yPercent: 110, stagger: 0.06, duration: 0.9 })
   - tl.from('.slogan', { y: 30, autoAlpha: 0, duration: 0.6 }, '-=0.45')
   - tl.from('.description', { y: 30, autoAlpha: 0, duration: 0.6 }, '-=0.4')
   - tl.from('.cta', { y: 20, autoAlpha: 0, scale: 0.96, duration: 0.5 },
     '-=0.35')
   Return the timeline in onSplit for the automatic cleanup.
3. The negative overlaps ('-=') make the elements enter in
   overlap, which gives fluidity (wait for one to finish 100% before starting
   the next in no case).
4. Use autoAlpha (opacity + visibility) in place of opacity alone, so
   invisible elements capture no clicks.

Motion 5 — Looping background video

Behind the text, the aerial flight runs without pause: cinematography rather than programmed animation. A native <video> with the four magic attributes, a poster, and React's known muted issue solved through a ref.

txt
In the Next.js Hero component, add the background video:

1. Structure: a section relative h-svh overflow-hidden
   - <video> absolute inset-0 w-full h-full object-cover -z-10 with the
     attributes: autoPlay, muted, loop, playsInline,
     preload="metadata", poster="/hero-poster.webp"
     and <source src="/hero.webm" type="video/webm" /> followed by
     <source src="/hero.mp4" type="video/mp4" /> (webm before mp4:
     the browser uses the first supported source).
   - Overlay: a div absolute inset-0 bg-black/40 (text legibility).
   - Watch the stacking: -z-10 presumes the section carries no
     background of its own covering the video.
2. IMPORTANT (React): the muted attribute sometimes fails to apply on the SSR
   (a known React issue); guarantee it through a ref in useEffect:
   videoRef.current.muted = true and call .play().catch(() => {})
   to cover the autoplay policies.
3. Performance: a video compressed in H.264 (mp4) + AV1/VP9 (webm),
   ~2-4MB max, 1080p, with no embedded audio track.
4. Accessibility: under prefers-reduced-motion, pause the video and show
   the poster alone.

Step 4 — Series production: the four scroll reveals

Four motions, one same pattern (y + autoAlpha fired by ScrollTrigger) in four variations: the site's reveal factory.

Motion 7 — Cascading cards with hover zoom

The photo cards appear apart from each other: ScrollTrigger.batch() reveals them in a cascade (~120ms between each). Then the hover gives a slow zoom to the photo inside the frame, and that part is pure CSS (group-hover:scale-110), because a simple hover needs no JS.

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

1. A grid of N cards (grid-cols-1 md:grid-cols-2 xl:grid-cols-4, gap-6).
   The card: a figure with overflow-hidden rounded-2xl holding next/image
   (fill, object-cover), an editorial number (01, 02...), an h3 and a paragraph.
2. Reveal with GSAP inside useGSAP (scoped to the container):
   ScrollTrigger.batch('.card', {
     start: 'top 85%',
     once: true,
     onEnter: (batch) => gsap.fromTo(batch,
       { y: 60, autoAlpha: 0 },
       { y: 0, autoAlpha: 1, duration: 0.8, ease: 'power3.out',
         stagger: 0.12 })
   })
   The initial state through gsap.set('.card', { autoAlpha: 0 }) so it fails to blink.
3. Hover zoom in CSS: on the card use the "group" class; on the image:
   transition-transform duration-700 ease-out group-hover:scale-110.
   On the number: transition-colors group-hover:text-[brand-color].
4. Images with next/image, correct sizes and a blur placeholder.
5. once:true guarantees the cascade runs a single time (it repeats in no case
   when you scroll up and down).

Motion 9 — Animated numeric counters

The large numbers count upward from zero to the final value, like a speedometer reaching the mark. The details that separate correct from broken: prefix/suffix as free strings (the real world holds "+C$" and "590K"), an explicit decimals (a heuristic would round 1.46 to "1.5", a wrong figure on screen), tabular-nums so the number avoids trembling in width, and the final value rendered in the server HTML, so the search engine indexes the real figure while the animation zeroes out and counts on the client alone.

txt
Create a client component "StatsGrid" in Next.js with GSAP counters:

1. Data: an array of N items with the type:
   type Stat = {
     label: string;        // the indicator name
     value: number;        // the final numeric value
     prefix?: string;      // 'R$', 'C$', '+', '+C$', for example
     suffix?: string;      // 'B', 'K', 'M', '%', for example
     decimals?: number;    // MANDATORY when the value carries significant
                           // decimal places (1.46 → decimals: 2)
     description: string;
   };
2. A responsive grid (2 → 5 columns). Each card: the label, an h3 for the number
   (a ref through a data attribute), a paragraph.
3. The counter: inside useGSAP, for each card:
   const obj = { val: 0 };
   gsap.to(obj, {
     val: item.value,
     duration: 1.6,
     ease: 'power2.out',
     scrollTrigger: { trigger: el, start: 'top 85%', once: true },
     onUpdate: () => {
       el.textContent = (item.prefix ?? '') +
         new Intl.NumberFormat('en-CA', {
           minimumFractionDigits: item.decimals ?? 0,
           maximumFractionDigits: item.decimals ?? 0
         }).format(obj.val) +
         (item.suffix ?? '');
     }
   });
   (Fixing minimum = maximum keeps the number from "trembling" in width
   during the count; consider font-variant-numeric:
   tabular-nums on the h3 as well.)
4. Card reveal in a cascade with ScrollTrigger.batch (stagger 0.08,
   y: 40 → 0, autoAlpha), the same pattern as Motion 7.
5. SSR/SEO: render the FINAL formatted VALUE in the initial HTML
   (server-friendly) and zero it out and animate on the client after mounting alone,
   so the search engine indexes the real number and users with no JS see the figure.
6. prefers-reduced-motion: skip the count and show the value directly.

Motion 10 — Text block reveal in sequence

Stacked argument blocks, each one with a divider line that draws itself from left to right (scaleX with transform-origin: left) before the title and the paragraph enter: an individual trigger per block, at the rhythm of the scroll.

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

1. N stacked blocks; each block: a decorative hr at the top, an h3 and a paragraph.
2. In useGSAP, iterate gsap.utils.toArray('.advantage'):
   for each block create a timeline with a scrollTrigger
   { trigger: block, start: 'top 80%', once: true }:
   - The line: fromTo scaleX 0 → 1, transformOrigin 'left center',
     duration 0.8, ease 'power2.inOut'
   - h3: from { y: 40, autoAlpha: 0 }, duration 0.7, position '-=0.4'
   - p: from { y: 24, autoAlpha: 0 }, duration 0.7, position '-=0.5'
3. Initial states set through gsap.set to avoid a flash.
4. Optional: turn it into an accordion (Radix UI Accordion + height animation
   with grid-template-rows: 0fr → 1fr in CSS) if the final design
   asks for blocks that expand on click.

Motion 12 — Multi-layer image parallax

A giant central title and scattered photos that move at different speeds during the scroll: layered depth, like the landscape through a train window. The depth factor gets declared through data-speed on each image; ease: 'none' is mandatory (under scrub, the smoothing comes from the scroll).

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

1. A relative section with min-h-[120vh]; a huge central title across 2 lines
   (with <strong> on the highlighted words); 2-4 images (next/image)
   positioned absolute at asymmetric points (a small one top-left,
   a medium one right-center, a large one bottom-left, for example), each with a
   data-speed attribute: 0.4, 0.8 and 1.4 for example.
2. In useGSAP, iterate gsap.utils.toArray('[data-speed]') and for each:
   gsap.to(el, {
     yPercent: -30 * parseFloat(el.dataset.speed),
     ease: 'none',
     scrollTrigger: {
       trigger: sectionRef.current,
       start: 'top bottom',
       end: 'bottom top',
       scrub: true
     }
   });
   ease 'none' is mandatory under scrub (the "smoothing" comes from the scroll).
3. The title enters with the SplitText pattern from Motion 3, fired by a
   ScrollTrigger (start 'top 75%', once) in place of load.
4. Compensate for the offset: position the images a touch "ahead"
   so they sit framed when the section reaches the center of the screen.
5. Disable the parallax on mobile through gsap.matchMedia('(min-width:768px)'),
   since small screens pay a performance cost with no visual gain.

Two Embla carousels with different personalities.

Motion 6 — Navigable horizontal timeline

The company history runs sideways: milestones with a large number and a card, navigated through arrows or drag, with the active item highlighted and a decorative line crossing the stations.

txt
Create a client component "HistoryTimeline" in Next.js with Embla Carousel:

1. Install embla-carousel-react. Data: an array of N milestones
   { label: string, title: string, description: string }
   (the label can be a year, a step number and so on).
2. const [emblaRef, emblaApi] = useEmblaCarousel({ align: 'start',
   dragFree: false, skipSnaps: false })
3. Structure: div.embla overflow-hidden (ref) > div.embla__container flex >
   slides with flex: 0 0 70% (desktop) / 0 0 85% (mobile), gap-8.
   Each slide: the label in text-7xl font-bold + a card with the title and text.
4. Prev/Next buttons call emblaApi.scrollPrev()/scrollNext(); disable them
   (opacity-30, cursor-default) at the ends using
   emblaApi.canScrollPrev()/canScrollNext() synced through the
   'select' event inside a useEffect (with listener cleanup).
5. The active slide: use selectedScrollSnap() to apply the highlight class
   (label in the brand color, the card at scale 1; inactive ones at opacity-50).
   Transitions through CSS transition-all duration-500.
6. A decorative horizontal line crossing the markers (a pseudo-element
   or an absolute div) with a dot at each station.
7. Extra: a reveal of the whole section with GSAP ScrollTrigger
   (y:60→0, autoAlpha, once: true) when it enters the viewport.

Services in an elegant carousel: a fixed concept image on one side, text panels swapping on the other, 01/02/03 markers that light up, optional autoplay, and the micro-entrance of the text on each swap through key={selectedIndex} on a motion.div.

txt
Create a client component "NumberedCarousel" in Next.js with Embla:

1. Install embla-carousel-react and embla-carousel-autoplay.
2. Data: N items { index: '01', title, description }.
3. useEmblaCarousel({ loop: true }, [Autoplay({ delay: 6000,
   stopOnInteraction: true })]). Autoplay is optional: expose it through a prop.
4. Layout: a 2-column grid. Left: a fixed concept image (next/image)
   that swaps in NO case; right: the Embla viewport with the N text panels.
   (Variation: if each item carries an image of its own, sync the image
   swap with the 'select' event.)
5. Bottom navigation: prev/next arrows + a row of numbered markers
   generated from scrollSnapList(); a click calls scrollTo(i).
   The active marker: the brand color + an animated underline
   (after:scale-x-0 → after:scale-x-100 with a transition, origin-left);
   inactive ones: opacity-40.
6. Sync the active index with the emblaApi 'select' event in a
   useEffect (with listener cleanup).
7. Internal micro-motion: when the slide changes, animate the h3 and the paragraph
   of the active panel with Framer Motion (key={selectedIndex} on a
   motion.div with initial { y: 24, opacity: 0 } → animate { y:0,
   opacity:1 }, duration 0.5) so the text "enters" on each swap.
8. Accessibility: aria-label on the arrows, role="tablist"/"tab" on the dots,
   autoplay paused on hover/focus.

Step 6 — The most delicate: a pinned section with step swapping (Motion 8)

On reaching the process section, the screen locks: you keep scrolling, but the section stays stuck, with the concept image fixed on one side while the steps take turns on the other, at the exact rhythm of your scroll. Like leafing through a book whose cover stays put.

It is the canonical pinned-section technique (pin: true + scrub + a timeline mapping the progress), and the most delicate one in the catalog: test the sync with Lenis hard, use gsap.matchMedia() for the mobile fallback (with no pin), and call ScrollTrigger.refresh() after the images load.

txt
Create a client component "ProcessSection" in Next.js with GSAP ScrollTrigger:

1. Layout: section > div.wrapper (a 2-column grid; left = the concept image
   with next/image; right = a div.steps relative where the N step panels
   stack with position absolute inset-0).
   Optional: an extra column or area of related links (a list of
   services + a "see more" button, for example), if the design asks for it; it stays outside the
   animated area of the steps.
2. Each panel: a "Step N" label, an h3 and a bullet list.
   Initial state: panel 1 visible; the rest with autoAlpha 0 and y 40.
3. Inside useGSAP, wrap EVERYTHING in gsap.matchMedia():
   const mm = gsap.matchMedia();
   mm.add('(min-width: 1024px)', () => {
     const tl = gsap.timeline({
       scrollTrigger: {
         trigger: wrapperRef.current,
         start: 'top top',
         end: '+=200%',            // ~2 viewports of "locked" scrolling;
                                   // adjust it proportionally to the number of steps
         pin: true,
         scrub: 0.8,
         anticipatePin: 1
       }
     });
     tl.to('.step-1', { autoAlpha: 0, y: -40, duration: 1 })
       .fromTo('.step-2', { autoAlpha: 0, y: 40 },
                          { autoAlpha: 1, y: 0, duration: 1 }, '<0.2')
       .to('.step-2', { autoAlpha: 0, y: -40, duration: 1 }, '+=0.5')
       .fromTo('.step-3', { autoAlpha: 0, y: 40 },
                          { autoAlpha: 1, y: 0, duration: 1 }, '<0.2');
     // generalize it in a loop for N steps
   });
   mm.add('(max-width: 1023.98px)', () => {
     // mobile: no pin; steps stacked with a simple reveal
     // (y + autoAlpha, a once ScrollTrigger per step)
   });
4. Add a vertical progress indicator (dots or a bar) that
   lights up along with the progress: use the ScrollTrigger onUpdate.
5. On the concept image, a subtle parallax: gsap.to(image, { y: -30,
   scrollTrigger: { trigger: wrapper, scrub: true } }).
6. Since the project may use Lenis, guarantee ScrollTrigger is synced
   already (Prompt 0). Call ScrollTrigger.refresh() after the section's
   images load.

Step 7 — The crown jewel: rotation through a frame sequence (Motion 13)

In the final section, an object seems to rotate without pause beside the headline. The trick is a century old: it is no 3D. They are N photographs at progressive angles swapping fast, like a flipbook. The same principle as cartoon animation, the same one Apple uses on its product pages.

Two decisions define the result: the frame count (9 frames in ~2s = a deliberate stop-motion aesthetic; 24–36 per revolution = fluid rotation, and decide before producing the assets) and the mode (an automatic loop or tied to the scroll, the "Apple effect"). And the fine detail of the loop: animate to N (rather than N−1) with a modulo in the draw, which is what eliminates the little jolt at the seam of the revolution.

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

1. N images in /public/sequence/frame-1.webp ... frame-N.webp
   (the same size, the same object position, progressive angles
   covering 360°).
2. A component with a <canvas ref> at fixed width/height (800x800 for example, displayed
   responsive through CSS w-full max-w-md aspect-square).
3. In useEffect: preload the N images
   (new Image(), a promise on img.onload; Promise.all before animating).
   Draw frame 0 as soon as it loads.
4. VERSION A — automatic rotation in a continuous loop:
   const frame = { i: 0 };
   const total = images.length;
   gsap.to(frame, {
     i: total,                       // to N (rather than N-1) + the modulo below:
     duration: 2.2,                  // it guarantees a wrap with no "jolt"
     ease: 'none',                   // at the jump from the last frame to
     repeat: -1,                     // the first
     snap: 'i',                      // locks to integers
     onUpdate: () => {
       ctx.clearRect(0, 0, w, h);
       ctx.drawImage(images[frame.i % total], 0, 0, w, h);
     }
   });
5. VERSION B — driven by scroll (the Apple effect): the same tween, but with no
   repeat, i: 0 → total - 1, and with scrollTrigger { trigger: section,
   start: 'top bottom', end: 'bottom top', scrub: true }, so the object rotates
   along with the scroll. Implement both behind a
   mode: 'auto' | 'scroll' prop.
6. Animate while visible alone (a ScrollTrigger with onToggle pausing and resuming
   the tween, or an IntersectionObserver) to save CPU.
7. The section texts (h2 + CTA) enter with the reveal pattern from motions
   3-4 through ScrollTrigger.
8. Cleanup: kill the tween and the observers on unmount (useGSAP with a
   scope covers the tween if it gets created inside the context).

Step 8 — The closing: form, languages and the overlay menu

Motion 15 — Animated form states

On submit, the form disappears with a fade and the success message takes its place, or the error in red with a subtle shake (x: [0, -6, 6, -4, 4, 0]). AnimatePresence mode="wait" orchestrates the swap; a reserved min-height avoids the layout jump.

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

1. react-hook-form + zod to validate the email; submission through a Server Action
   (app router) with the states: 'idle' | 'loading' | 'success' | 'error'
   in useState.
2. Rendering with <AnimatePresence mode="wait">:
   - status !== 'success': a motion.form key="form" with the input + button;
     exit { opacity: 0, y: -10 }.
   - status === 'success': a motion.p key="ok" initial { opacity:0, y:10 }
     animate { opacity:1, y:0 }: the project's success message.
   - status === 'error': a motion.p key="err" with a subtle shake:
     animate={{ x: [0, -6, 6, -4, 4, 0] }} transition 0.4s, in red.
3. The button in loading: a spinner + disabled + a light scale 0.98.
4. Reserve a min-height on the wrapper to avoid layout shift on the swap.
5. Focus and accessibility: aria-live="polite" on the message region.

Motion 17 — Language selector + route fade

InterGest serves German companies expanding into Canada, so the "Deutsch" toggle in the header is strategy rather than decoration. In Next.js: next-intl with prefix routing, an underline that draws itself on hover, and the global route fade through template.tsx.

txt
Implement the language selector in Next.js:

1. Configure next-intl with the project's locales (['pt', 'en'] for example) and
   prefix routing (/en/...). Create messages/<locale>.json for each.
2. A "LocaleSwitcher" component: use the next-intl navigation helpers
   (createNavigation → { Link, useRouter, usePathname }) to change the
   locale while preserving the current route.
3. Link styling: relative, with after: absolute bottom-0 left-0 h-px
   w-full bg-current scale-x-0 origin-left transition-transform
   duration-300 hover:after:scale-x-100 (an underline that draws itself).
4. Mark the active language (aria-current, font weight) and set the
   correct lang attribute on <html>.
5. The page transition can gain a global fade: wrap the layout children
   in a template.tsx with Framer Motion (a motion.div initial
   { opacity: 0 } animate { opacity: 1 } duration 0.35), since template.tsx
   remounts on each navigation, firing the animation.

Motion 1 — Fullscreen overlay menu with stagger

Closing the cycle, the menu: a dark curtain descends covering the screen (a clipPath from top to bottom), and the links appear one after another, rising from masks: variants with staggerChildren, the native language of Framer Motion for cascades. With the complete ergonomics: Escape, a focus trap, focus returned to the burger, lenis.stop()/start().

txt
Create a Next.js client component "FullscreenMenu" with Framer Motion:

1. A burger button in the header controls the open state (useState). The icon
   animates between the burger and an X (two lines rotating 45°/-45°).
2. Use <AnimatePresence> to mount and unmount the overlay:
   - Overlay: a div fixed inset-0 z-50, a dark background; it enters with
     clipPath: 'inset(0 0 100% 0)' → 'inset(0 0 0% 0)' (a curtain effect
     from top to bottom), duration 0.7, ease [0.76, 0, 0.24, 1];
     on exit, it reverses.
3. The navigation links (a count parameterized through an array) use variants:
   - container: staggerChildren: 0.07, delayChildren: 0.3
   - item: hidden { y: '110%', opacity: 0 } → visible { y: 0, opacity: 1 },
     duration 0.6, ease easeOut. Each link sits inside a wrapper with
     overflow-hidden for the "mask" effect (the text appearing from below).
4. An optional side column with content cards: fade + y:20, delay 0.5.
5. Scroll lock while open:
   - With Lenis: call lenis.stop() on open and lenis.start() on close
     (through the useLenis hook from Prompt 0). Add data-lenis-prevent to the
     overlay container if it holds scrollable content inside.
   - Without Lenis: document.body.style.overflow = 'hidden' with a restore in the
     cleanup.
6. Close with the Escape key and implement a focus trap (focus held inside the
   overlay while open; on close, return the focus to the burger button).
7. Respect prefers-reduced-motion: when active, use a quick fade alone.
Large typography (text-5xl+), links with a hover that shifts 8px to the right.

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

  • "use client" is mandatory in every component that touches GSAP, Lenis or Framer Motion.
  • Always useGSAP({ scope }) in place of a raw useEffect, since it runs an automatic context.revert() on unmount, killing orphan tweens and ScrollTriggers (bug cause number 1 when navigating between App Router routes).
  • The Lenis cleanup takes 3 steps: gsap.ticker.remove(update) + lenis.off('scroll', ...) + lenis.destroy(). Forgetting the ticker.remove leaves the ticker calling raf() on a dead instance every frame.
  • Locking the scroll with Lenis: use lenis.stop()/start() rather than overflow: hidden on the body alone; and data-lenis-prevent on overlays with scrollable content.
  • gsap.matchMedia() for animation responsiveness; GSAP deprecated ScrollTrigger.matchMedia back in 3.11.
  • ScrollTrigger.refresh() after images and fonts load, since trigger positions computed before the final layout come out wrong.
  • SplitText after the fonts alone: use autoSplit: true + onSplit (GSAP 3.13+) or wrap it in document.fonts.ready.then(). A split before the font means wrong line breaks.
  • A content flash (FOUC): initial states of animated elements belong in gsap.set/autoAlpha or an initial CSS class, and in the first animation frame in no case.
  • Pin + Lenis: it works well as long as the Prompt 0 sync is done; use smooth scroll from two libraries at the same time in no case.
  • Animate the hero title a single time: if the hero carries a complete timeline (Motion 4), the standalone tween from Motion 3 should exist in no parallel form.
  • prefers-reduced-motion on everything, since beyond accessibility it avoids nausea in sensitive users (and it is a Lighthouse/axe audit criterion).

Where to start today

The entry trio: Step 1 (the infrastructure, where the three-step Lenis cleanup is worth the step on its own), Step 2 (marquee, buttons and cookies, three quick wins that exercise the division of turf already) and Step 3 (the complete hero, remembering the golden rule: the title animates once, inside the timeline).

And with the fifth chapter, the series gains its counterpoint: after the authored theses of Plantica, ANAI, Studio Modular and Beetogreen, InterGest shows the classic arrangement executed well: GSAP choreographing the page, Framer Motion managing what is born and dies, Embla on the rails and CSS on what runs by itself. It is the least daring architecture in the series; it is the one you will use most. Mastering the borders between the tools is what keeps the codebase from turning into a fight between libraries, and training that is the whole point of this catalog.