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

Contents
- The philosophy: each tool on its own turf
- The stack and the role of each piece
- The map of the 17 motions
- Step 1 — The foundation: animation infrastructure
- Step 2 — Quick wins: marquee, CTA buttons and cookie banner
- Step 3 — The calling card: a complete hero over video
- Step 4 — Series production: the four scroll reveals
- Step 5 — The rails: horizontal timeline and numbered carousel
- Step 6 — The most delicate: a pinned section with step swapping (Motion 8)
- Step 7 — The crown jewel: rotation through a frame sequence (Motion 13)
- Step 8 — The closing: form, languages and the overlay menu
- The 11 traps that will bite you (read before coding)
- 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:
AnimatePresencesolves 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
| Library | Role in the project | Why |
|---|---|---|
| GSAP 3.13+ and @gsap/react | The backbone: reveals, timelines, split text, pin/scrub | GSAP is 100% free, every plugin included (SplitText, ScrollTrigger, Draggable), since the Webflow acquisition |
| ScrollTrigger (a GSAP plugin) | Every animation fired or driven by scroll | Section pinning, scrub, cascading reveals |
| Lenis | Global 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 hover | AnimatePresence solves component mounting and unmounting, which is painful in raw GSAP. The current package goes by motion (Motion for React) |
| Embla Carousel | The horizontal timeline and the content carousel | Light, headless, accessible, with an API for custom dots and arrows |
The map of the 17 motions
| # | Motion | Typical use | Trigger |
|---|---|---|---|
| 1 | Fullscreen overlay menu | Header (the burger) | Click |
| 2 | Cookie banner | Global (first visit) | Load |
| 3 | Hero title reveal (split text) | Hero | Load |
| 4 | Sequential entrance of the subtitle/CTA | Hero | Load |
| 5 | Looping background video | Hero | Automatic |
| 6 | Navigable horizontal timeline | The history/steps section | Arrows/drag |
| 7 | Cascading cards + hover zoom | A grid of cards with photos | Scroll + hover |
| 8 | Pinned section with step swapping | The process section | Scroll |
| 9 | Animated numeric counters | The statistics grid | Scroll |
| 10 | Text block reveal | Argument/advantage blocks | Scroll |
| 11 | Carousel with numbered pagination | The services/features section | Click/auto |
| 12 | Multi-layer image parallax | An editorial section with photos | Scroll |
| 13 | Rotation through a frame sequence | A "fake" 3D object (globe, product) | Auto/scroll |
| 14 | Infinite marquee | Footer/decorative bands | Automatic loop |
| 15 | Animated form states | Newsletter/contact | Submit |
| 16 | CTA button hover | Global buttons | Hover |
| 17 | Language selector + route fade | Header | Hover/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.
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.Step 2 — Quick wins: marquee, CTA buttons and cookie banner
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.
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 · </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.
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.Motion 2 — Sliding cookie banner
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.
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.
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.
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.
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.
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.
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.
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).
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.Step 5 — The rails: horizontal timeline and numbered carousel
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.
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.Motion 11 — Carousel with numbered pagination
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.
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.
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.
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.
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.
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().
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 rawuseEffect, since it runs an automaticcontext.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 theticker.removeleaves the ticker callingraf()on a dead instance every frame. - Locking the scroll with Lenis: use
lenis.stop()/start()rather thanoverflow: hiddenon the body alone; anddata-lenis-preventon overlays with scrollable content. gsap.matchMedia()for animation responsiveness; GSAP deprecatedScrollTrigger.matchMediaback 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 indocument.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/autoAlphaor 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-motionon 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.
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


