Skip to content
Zumkai

Premium motions in Next.js: the 18 patterns of the Plantica site, step by step

The animation architecture of the Plantica site, broken down into 18 motion patterns with a ready-made implementation prompt for each one.

  • motion
  • gsap
The Plantica home page: the typographic hero "Plantica is a flower creative studio" with pill navigation at the top
Contents
  1. The philosophy that changes everything: CSS-first, JS-orchestrated
  2. The stack and the role of each piece
  3. The map of the 18 motions
  4. Step 1 — The foundation: animation infrastructure
  5. Step 2 — The engine: scroll becomes a CSS custom property (Motion 4)
  6. Step 3 — The typographic signature: clip/split text (Motion 3)
  7. Step 4 — Global states: first load and page transitions
  8. Step 5 — The chrome: 3D menu card, buttons and underlines
  9. Step 6 — The calling card: hero collage with progressive zoom (Motion 5)
  10. Step 7 — Sections in series: archive, sticky panels and video iris
  11. Step 8 — Desktop polish: contextual cursor and floating preview
  12. Step 9 — The detail page: split-scroll, FLIP zoom and related items
  13. Step 10 — Infinite scroll in the galleries (Motion 6)
  14. Step 11 — The closing: decorative sprites and the footer in three acts
  15. The 15 traps that will bite you (read before coding)
  16. Where to start today

Every developer has seen one of those creative-studio sites that seem to play in another league: the text enters letter by letter, the hero grows until it swallows the screen, the footer gets revealed like a curtain, the cursor tells you what each area does. And the question that lingers is always the same: how is that built for real, and how do I reproduce it in a Next.js project without ending up with a Frankenstein of libraries?

This guide answers by reverse-engineering a concrete case: the Plantica site (plantica.net), the Japanese floral creative studio founded by Takashi Kimura. "Plantica is a flower creative studio", the hero announces, and behind the delicate appearance sits an exemplary custom build: GSAP + ScrollTrigger + Lenis + barba.js + WebGL over a headless CMS. An audit of that site produced the 18 patterns below, but the material is agnostic about content: names, texts and images are parameterizable placeholders. The part that matters, and the part you take away from here, is the movement pattern, the technique behind it and an implementation prompt ready to paste into your AI agent or to use as a specification.

The order of the steps departs from the order of the catalog: it is the order of construction. Each step lays the base for the next, so you can follow the guide as a project script, from zero to the complete site.

The philosophy that changes everything: CSS-first, JS-orchestrated

Before any line of code, understand the architecture decision that holds the audited site together, because it is the opposite of what most people do.

The common approach tweens everything through JavaScript: each animated element becomes a GSAP tween or a Framer Motion component, and the JS recalculates values every frame. It works, but it fails to scale: dozens of tweens running on the main thread, fragile cleanup, and any layout refactor breaks the timings.

The audited site does otherwise. The JavaScript animates close to nothing on its own. It holds two jobs and no more:

  • Writing the scroll progress into CSS custom properties (--p1 and --p2), through a single ScrollTrigger pattern with scrub.
  • Toggling state classes on <html>: is-loaded, is-leaving, is-menu-opened, data-shown.

The real animating comes from transitions and calc() in CSS, running on the compositor. The result: performance in another category (no layout thrashing per frame), trivial maintenance (the motions become CSS declarations and markup attributes) and a simple mental map: JS measures and orchestrates, CSS moves.

The stack and the role of each piece

LibraryRole in the projectWhy
GSAP 3.13+ and @gsap/reactThe scroll progress engine (ScrollTrigger scrub → CSS vars) and one-off orchestrationsGSAP is 100% free, every plugin included, since the Webflow acquisition. Here it serves in a minimalist way: one ScrollTrigger factory for the whole site
ScrollTrigger (a GSAP plugin)Writing --p1 and --p2 on the elements carrying data-start and data-endA very short scrub (0.01–0.1) for almost raw progress; the visual smoothing comes from Lenis + CSS
LenisGlobal smooth scroll and infinite mode on gallery pagesThe audited site uses Lenis with infinite: true for vertical image loops
Framer Motion (motion)Route transitions (template.tsx) and one-off UI statesIt replaces the barba.js role of the original site inside the App Router paradigm
OGL (or raw WebGL)A blurred gradient canvas that follows the mouseThe original uses raw WebGL of a few KB; OGL delivers the same result with less boilerplate than three.js
CSS custom properties and transitionsThe real animation enginetransform: calc(...) consuming --p1/--p2; transitions with delays through data-d; keyframes for the marquee

You need no carousel plugin: the horizontal lists use overflow + native drag. If you prefer an abstraction, Embla Carousel is compatible with every pattern here.

The map of the 18 motions

#MotionTypical useTrigger
1Splash/preloader + orchestrated entranceThe site's first loadLoad
2Page transition (exit/entrance)Every internal navigationNavigation
3Clip/split text systemTitles and paragraphs, across the siteLoad/Scroll
4Scroll → CSS vars engine (--p1/--p2)The base of motions 5, 12, 13, 14, 17, 18Scroll
5Hero collage with progressive zoomThe home heroScroll
6Smooth scroll + infinite scrollGlobal + detail galleriesScroll
73D menu cardHeader (the Menu button)Click
8Ghost buttons + animated underlinesGlobal buttons and linksHover
9Custom cursor with labelsDesktop, media areasMouse
10Floating preview on list hoverItem listsHover
11Archive: reveals, sticky sort, filtersListing pagesScroll/Click
12Panels over a sticky backgroundThe featured section on the homeScroll/Hover
13Video expanding through clip-pathThe institutional video sectionScroll
14Split-scroll detail pageProject/case detailScroll
15Click-to-zoom on an image (FLIP)Detail galleriesClick
16Horizontal list with dragRelated/next itemsDrag
17Decorative sprites (pop + parallax)The institutional pageLoad/Scroll
18Composite footerThe global footerScroll/Mouse

Generic section reveals carry no number of their own: they reuse the text system of Motion 3 and the fade/clip pattern with data-shown through IntersectionObserver.

Step 1 — The foundation: animation infrastructure

Everything depends on this step. Here the smooth scroll provider is born, along with the single registration of the GSAP plugins, the easing tokens in CSS and the two hooks the whole site consumes. Note three decisions that tutorials tend to ignore and that stay non-negotiable here. The three-step Lenis cleanup: without it, each navigation leaks a ticker. The easings living in CSS, because the CSS is what animates. And the prefers-reduced-motion hook from day zero, since accessibility is a foundation rather than a final layer.

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 ogl
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
       lenis.off('scroll', ScrollTrigger.update);   // instance raf()
       lenis.destroy();                             // dead (leak)
   - Expose the instance through React Context with a useLenis() hook, so
     other components can call lenis.stop()/start() (menu, zoom).
3. Register the plugins once in a lib/gsap.ts file:
   gsap.registerPlugin(ScrollTrigger, useGSAP) and export gsap.
   Client components alone should import that file.
4. Create the site's CENTRAL ENGINE: the "useScrollProgress" hook (detailed in
   Motion 4) that reads [data-scroll-trigger] elements and writes --p1/--p2.
5. Create a usePrefersReducedMotion() hook reading the media query
   (prefers-reduced-motion: reduce); every animation component
   should skip or reduce animations when it is true.
6. Define the easing and duration tokens in globals.css as CSS vars
   (the whole site animates through CSS, so the easings live in CSS):
   :root {
     /* CSS approximations of the GSAP easings (values from easings.net) */
     --ease-power2-out: cubic-bezier(0.5, 1, 0.89, 1);      /* quadOut  */
     --ease-power4-out: cubic-bezier(0.25, 1, 0.5, 1);      /* quartOut */
     --ease-power2-in-out: cubic-bezier(0.45, 0, 0.55, 1);  /* quadInOut */
     --a-clip-s: 1s;        /* duration of the text reveals */
     --a-global-t-s: 0.8s;  /* duration of global UI (menu, header) */
     --a-hover-s: 0.4s;     /* duration of hovers */
     --vh: 1vh;             /* corrected through JS for mobile (resize) */
   }
7. Wrap {children} in the root layout.tsx with <SmoothScrollProvider>.
Deliver the complete files and the updated layout.tsx.

Step 2 — The engine: scroll becomes a CSS custom property (Motion 4)

This is the heart of everything, and it is invisible by definition: it is the wiring. A single mechanism feeds the hero that grows, the panels that slide, the video that expands and the footer that reveals itself. Implement it before any visual motion, because motions 5, 12, 13, 14, 17 and 18 consume it and nothing more.

How it works: a ScrollTrigger factory sweeps the elements marked with data-scroll-trigger, reads the data-start and data-end attributes and, with a very short scrub, writes two variables on each element: --p1 (progress, 0 to 1) and --p2 (its inverse). All the rest is CSS: transform: translateY(calc(var(--p1) * -50vh)), scale(calc(1 + var(--p2) * 0.5)). Easy on the compositor, zero React re-render, and the motions turn declarative in the markup.

The detail that separates functional from professional: initializing --p1: 0 and --p2: 1 in the CSS, rather than through JS alone. That is what makes the server HTML render the top state already, with no layout flash on first paint, and it is essential in a Next.js project with SSR.

txt
Create the scroll progress engine in Next.js:

1. A client hook "useScrollProgress(scopeRef)" that, inside useGSAP:
   gsap.utils.toArray('[data-scroll-trigger]', scope).forEach((el) => {
     ScrollTrigger.create({
       trigger: el,
       start: el.dataset.start ?? 'top bottom',
       end: el.dataset.end ?? 'bottom top',
       scrub: 0.01,
       onUpdate: (self) => {
         el.style.setProperty('--p1', String(self.progress));
         el.style.setProperty('--p2', String(1 - self.progress));
       }
     });
   });
   Initialize --p1: 0 and --p2: 1 through CSS (:where([data-scroll-trigger])
   { --p1: 0; --p2: 1 }) so the SSR renders the top state with no
   layout flash.
2. Declarative syntax in the markup (identical to the audited site):
   <section data-scroll-trigger data-start="top top"
            data-end="bottom+=20% top">...</section>
3. The child elements consume the vars with transform/opacity ONLY
   (never width/height/top: they cause layout). Examples:
   .bg   { transform: scale(calc(1 + var(--p2) * 0.5)); }
   .card { transform: translateY(calc(var(--p1) * -100vh)); }
   .box  { border-radius: calc(var(--r) * var(--p2)); }
4. will-change: transform on the elements that actually animate alone, and
   toggled (through a class) when the section sits in the viewport, for preference.
5. Call ScrollTrigger.refresh() after images/fonts load and after
   route transitions (Motion 2).
6. prefers-reduced-motion: create no triggers; freeze --p1: 0/--p2: 1
   (a static initial state).

Step 3 — The typographic signature: clip/split text (Motion 3)

No text on the site arrives unannounced. Titles enter letter by letter, each character rising from behind an invisible cut line, with a minimal delay between them: the wave effect. Smaller texts enter by word or by line. And the same mechanism, in reverse, takes the texts out during page transitions. It is the "luxury editorial" signature of the whole site, and close to every other motion uses it, which is why it comes before them.

The mechanics: each text unit gets wrapped in a <span class="o"> (a mask with overflow: hidden) holding a <span class="t"> (the text, starting at translateY(110%)). The animation is a CSS transition fired by the change of the data-shown attribute, which an IntersectionObserver (or the load from Step 4) toggles. The cascading delays come from the character index, through a CSS var.

txt
Create the text reveal system in Next.js:

1. A client component "SplitReveal" with props:
   type: 'chars' | 'words' | 'lines', delay (s between units,
   default 0.05), trigger: 'load' | 'inview', as: the element tag.
2. Split: use GSAP's SplitText (free in 3.13+) with
   { type, mask: type } inside useGSAP, to GENERATE the
   wrappers alone (.o overflow-hidden > .t); the animation itself is CSS.
   Await the fonts with autoSplit: true (or document.fonts.ready)
   so the line breaks come out right.
3. CSS:
   .split .t {
     display: inline-block;
     transform: translate3d(0, 110%, 0);
     transition: transform var(--a-clip-s) var(--ease-power4-out),
                 opacity 0.6s;
     transition-delay: var(--d, 0s);
   }
   [data-shown="true"] .t { transform: translate3d(0, 0, 0); }
   Assign --d per unit: style={{ '--d': `${i * delay}s` }}.
4. trigger 'inview': a useInView hook (IntersectionObserver,
   threshold 0.2, once) sets data-shown="true".
   trigger 'load': it reacts to the is-loaded-a class from Motion 1.
5. Compatibility with Motion 2: when <html> carries .is-leaving, the global
   CSS overrides with the exit (translateY(-50%), fade, delay 0).
6. Care points: aria-label with the original text on the parent element and
   aria-hidden on the spans (screen readers should spell nothing out);
   prefers-reduced-motion swaps it all for a plain fade.
7. Fine kerning: expose a prop for per-character adjustments
   (negative letter-spacing on specific pairs), applied through
   data-char, the way the audited site does.

Step 4 — Global states: first load and page transitions

These two motions establish the state classes (is-loaded, is-leaving) that the whole CSS references. Without them, the reveals from Step 3 have nothing to fire them on load, and navigation turns into a hard cut.

Motion 1 — Splash and orchestrated entrance

The page fails to burst onto the screen all at once. First everything stays invisible for a moment; then the top elements enter in choreography: the logo letters rise one by one from inside a mask, the menu items appear in sequence, and the main content starts its own reveal after that. A theater curtain opening, everything in its time.

And here sits the elegance of the architecture: no JS tween. The <html> starts with a "not loaded" class; when the fonts and critical assets load, the JS adds is-loaded (and, a frame later, is-loaded-a). All the rest is CSS transitions with a per-element transition-delay: a data-d="1..N" attribute that becomes transition-delay: calc(var(--d) * 0.05s).

txt
Implement the orchestrated entrance in Next.js:

1. In globals.css: body { visibility: hidden } and
   .is-loaded body { visibility: visible } (a blunt anti-FOUC, the same as
   the premium-site pattern). Include a <noscript> with alternative CSS
   forcing visibility: visible (accessibility with no JS).
2. A client component "LoadOrchestrator" mounted in the root layout:
   - It awaits document.fonts.ready and the load of the hero's critical assets
     (Promise.all with decode() of the above-the-fold images).
   - It adds 'is-loaded' to <html>; on the next requestAnimationFrame,
     it adds 'is-loaded-a' (two stages let the CSS tell
     "ready" from "may animate").
3. The header elements (logo letters, nav items) use the mask pattern
   from Motion 3, each one with a data-d="1..N" attribute. In the CSS:
   [data-d] .t { transition-delay: calc(var(--d) * 0.06s) }
   (pass the number to the --d CSS var through an inline style; use no attr()
   inside calc, since browser support remains limited).
   Initial state: translateY(110%); with .is-loaded-a: translateY(0).
4. Optional: a centered splash logo with a 1.2s fade before the header.
5. prefers-reduced-motion: skip the delays and reveal everything with a short fade.
6. Guarantee the orchestration runs ONCE per session (sessionStorage);
   on internal navigations, Motion 2 takes over.

Motion 2 — Page transition

On clicking a link, the current page disappears in no blink: the texts slide upward and dissolve in a fade (~0.6s), and the new page enters with the titles rising from the masks, in the same language as the first load. Navigation becomes a continuation of the choreography.

On the original site barba.js held that role. In Next.js, the App Router takes over: Link and native prefetch handle the swap; the exit comes from a global is-leaving class applied before the router.push (with a small await), and the entrance comes from template.tsx remounting and the reveal system reacting to the new mount.

txt
Implement page transitions in the Next.js App Router:

1. Create a TransitionProvider (client) with a context { isLeaving,
   navigate(href) }:
   - navigate: sets isLeaving=true (adding the 'is-leaving' class to
     <html>), awaits ~600ms (the exit duration) and calls router.push(href).
   - Every internal link uses a <TransitionLink> component that
     intercepts the click and calls navigate (keeping <Link> underneath
     to preserve prefetch).
2. Exit CSS (the same language as the audited site):
   .is-leaving .c-clip .t, .is-leaving .c-fade {
     transition: all 0.6s var(--ease-power2-in-out) !important;
     transition-delay: 0s !important;   /* ignores the entrance delays */
     opacity: 0 !important;
     transform: translateY(-50%) !important;
   }
3. Entrance: template.tsx remounts on each navigation; inside it, an effect
   removes 'is-leaving' and re-fires the Motion 3 pipeline (the titles
   of the new page enter through the masks). A global container fade
   (Framer Motion, opacity 0→1, 0.35s) covers the moment of the swap.
4. On a route change: lenis.scrollTo(0, { immediate: true }) before the
   entrance, and ScrollTrigger.refresh() after the new page's images
   load.
5. Accessibility: announce the navigation (aria-live) and respect
   prefers-reduced-motion with a plain fade cut.
6. Block no browser navigation (back/forward): detect popstate
   and skip the exit phase in those cases.

Step 5 — The chrome: 3D menu card, buttons and underlines

The menu validates the global states you created a moment ago, and the buttons are the quick win that shows up all over the site.

Motion 7 — 3D menu card with a label flip

On clicking "Menu", a rounded card materializes in the top corner: it settles with a sense of depth (scale + offset under 3D perspective, like a card landing on a table), while the button label turns from "Menu" to "Close" with a vertical flip. Inside, the links enter in a cascade, with discreet counters beside them.

The architecture trick: no AnimatePresence. The card sits in the DOM at all times (great for the SEO of the links) and switches state through an is-menu-opened class on <html>. The parent carries perspective: 200vw; the card goes from scale(0.95) translateY(1rem) + opacity 0 to identity, all of it a CSS transition.

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

1. A fixed structure in the corner: div.menu (pointer-events none when closed) >
   div.in (perspective: 200vw) > [header with the toggle button] +
   div.menu-bg (rounded background) + div.menu-body (content).
2. States through a class on <html> (a useEffect toggling 'is-menu-opened'):
   .menu-bg, .menu-body {
     transform: scale(.95) translateY(1rem);
     transform-origin: top center; opacity: 0;
     transition: opacity .3s,
                 transform var(--a-global-t-s) var(--ease-power4-out);
   }
   .is-menu-opened .menu-bg, .is-menu-opened .menu-body {
     transform: scale(1) translate(0); opacity: 1;
   }
   The audited site defines perspective but keeps rotation at 0 (the
   depth comes from the scale + translate under perspective). To
   accentuate the 3D, add rotateX(-4deg) to the closed state (optional).
3. A toggle button with a label flip: an overflow-hidden mask with TWO
   stacked divs ("Menu" / "Close"); open → translateY(-100%)
   reveals "Close". The same button inside the card closes it (correct
   aria-expanded, aria-controls).
4. Main links with the Motion 3 split (cascading delays) and a
   discreet counter beside them (the item count, coming from the CMS);
   secondary/social links in a grid; a language selector in circular
   dots (aria-current on the active one).
5. Behavior: Escape closes; a click outside closes; lenis.stop()/start()
   on open/close; focus trapped in the card while open and returned
   to the button on close; data-lenis-prevent if the card scrolls.
6. On navigating through a menu link: close the card in parallel with the
   Motion 2 transition.
7. prefers-reduced-motion: a plain fade with no rotate or scale.

Motion 8 — Ghost buttons and animated underlines

Two families of micro-interaction, 100% CSS. On the pill buttons, the label slides up and an identical copy replaces it from below (a flip with a mask), while a colored circle inflates from inside the button until it fills the pill: the background is born round and spreads. On text links, an underline slides out and comes back from the other side, or draws itself from left to right.

The micro-detail that gives it the finish: in the flip, the first copy travels to -120% and the second to -100%. That minimal mismatch between the two is the micro-stagger that makes the movement look organic in place of mechanical.

txt
Create the "GhostButton" and "UnderlineLink" components in Next.js:

1. GhostButton (a pill with a border):
   - Markup: <a class="btn"> <span class="o"> <span class="w">Label
     </span><span class="w" aria-hidden>Label</span> </span> </a>
   - CSS:
     .btn { position: relative; overflow: hidden; border-radius: 2em;
            border: 1px solid var(--col-border); }
     .btn::after { content: ''; position: absolute; inset: 0;
       background: var(--col-black); border-radius: 50%;
       transform: translateY(100%); z-index: 0;
       transition: border-radius .3s var(--ease-power4-out),
                   transform var(--a-global-t-s) var(--ease-power4-out); }
     .btn .o { overflow: hidden; position: relative; z-index: 1; }
     .btn .w { display: block; transition: transform
               var(--a-global-t-s) var(--ease-power4-out),
               color .3s; }
     .btn .w:last-child { position: absolute; top: 100%; }
     .btn:hover::after { border-radius: 2em; transform: translate(0); }
     .btn:hover .w { transform: translateY(-100%); color: var(--col-white); }
     .btn:hover .w:first-child { transform: translateY(-120%); }
     (the -120% vs -100% creates the micro-stagger between the copies)
   - The "current"/active state reuses the hover in a persistent form.
2. UnderlineLink:
   - Slide variant: <span class="b"> absolute at the base, translateX(-101%),
     hover → translateX(0), transition var(--a-hover-s)
     var(--ease-power2-out).
   - Draw variant (for links in running text): background-image
     linear-gradient, background-size 0 1px → 100% 1px through keyframes
     on hover (which allows a "redraw" on each hover).
3. Internal routes: use next/link (or the TransitionLink from Motion 2);
   external ones: <a> with rel="noopener".
4. Hover states on devices with real hover alone:
   @media (hover: hover); on touch, the states stay static.
5. focus-visible:ring present at all times; remove an outline in no case without a replacement.

Step 6 — The calling card: hero collage with progressive zoom (Motion 5)

The top of the site is a collage: one central image and several smaller ones scattered around it, all with rounded corners, entering in a cascade on load. On scroll, the magic: the central image grows without pause until it takes the whole screen, the corners relax until they reach a pure rectangle, and the satellites zoom inward and leave the scene. The scroll seems to enter inside the image.

This is the first big consumer of the Step 2 engine, and the test that your foundation is right. The structure: a container 200–230vh tall with a position: sticky; top: 0; height: 100vh child. The container is a data-scroll-trigger from top top to bottom top; everything inside animates through CSS consuming --p1 and --p2.

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

1. Structure:
   <section class="hero" data-scroll-trigger data-start="top top"
            data-end="bottom+=20% top">   <!-- height: 230vh -->
     <div class="hero-sticky">            <!-- sticky top-0 h-screen,
                                               overflow-hidden -->
       <div class="hero-mv">…central visual…</div>
       <div class="hero-img" data-x="0" data-y="0">…</div>
       <!-- N satellite images positioned through data-x/data-y -->
     </div>
   </section>
2. CSS (consuming the Motion 4 engine):
   .hero-mv {
     transform: scale(calc(0.6 + var(--p1) * 0.4 + var(--p2) * 0.1))
                translate(calc(var(--hero-x) * var(--p2)),
                          calc(var(--hero-y) * var(--p2)));
     border-radius: calc(var(--r) * var(--p2));
     overflow: hidden;
   }
   .hero-img .img { transform: scale(calc(1 + var(--p1) * 0.3)); }
   The satellite positions come from a data-x/data-y → top/left/size map
   in CSS (a virtual grid), as on the audited site.
3. Entrance on load: each image carries a data-delay (0, 0.1, 0.2, ...);
   with .is-loaded-a, opacity + scale transitions reveal the collage
   in a cascade (the Motion 1 pattern). The hero title uses the chars
   split from Motion 3, across 2 lines.
4. Images: next/image with fill + sizes; load @pre versions
   (a blur placeholder) and @2x. The satellites leave the visual DOM at the end
   (opacity through --p1) but keep their space.
5. Mobile: shrink the collage (fewer satellites, through CSS/data-mb) and
   reduce the scroll distance (200vh); gsap.matchMedia if you need
   different triggers.
6. prefers-reduced-motion: a static 100vh hero with the central visual
   at its final scale already.

Step 7 — Sections in series: archive, sticky panels and video iris

With the base ready, these three motions come off the line in series, since they all reuse patterns you hold already.

Motion 11 — Archive: reveals with a border draw, sticky sort and filters

Three behaviors make a listing look alive. On scroll, each divider line draws itself and the card rises with a cascading fade, with an internal zoom on the thumbs on hover. The sorting header sticks to the top and shrinks with a smooth move as it lands. And clicking "Filter" opens a panel that slides open by expanding its height, with the options entering in sequence.

Two techniques deserve the didactic spotlight here. The shrinking sticky uses a 1px sentinel watched by an IntersectionObserver, with no scroll listener measuring offsets. And the expanding panel uses grid-template-rows: 0fr → 1fr, the modern way to animate height without measuring a single pixel through JS.

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

1. A responsive grid (1 → 3/4 columns) of cards: a thumb (next/image in an
   overflow-hidden rounded frame), a title, pill tags (the GhostButton
   from Motion 8 in a mini version).
2. Reveals: each card with data-shown through useInView (once). The divider
   line: .border { overflow: hidden; height: 1px }
   .border > div { height: 100%; background: currentColor;
   transform: translateY(-110%); transition: transform .8s
   var(--ease-power2-out); } [data-shown] .border > div
   { transform: translateY(0); } Title/text with Motion 3.
3. Hover: the thumb with img scale(1.05), transition .7s ease-out
   (group-hover); the "View" cursor (data-cursor from Motion 9).
4. A sticky sorting header:
   - position: sticky; top: 0; the same background as the page; a z-index
     above the grid.
   - A 1px sentinel above the header watched by an IntersectionObserver;
     when it leaves the viewport → the 'is-stuck' class on the header →
     .title { transform: scale(0.8); transform-origin: left bottom;
     transition: transform var(--a-global-t-s) var(--ease-power4-out) }
     and reduced paddings (through transform/margins, rather than height).
5. A filter panel (a slide toggle):
   - Wrapper display: grid; grid-template-rows: 0fr;
     transition: grid-template-rows .6s var(--ease-power4-out);
     open → 1fr. The child with min-height: 0; overflow: hidden.
   - The button arrow rotates 90°; the options enter with a stagger (Motion 3).
   - aria-expanded/aria-controls on the button.
6. Applying a filter: update the list (searchParams/state) and re-fire
   the reveals of the new cards alone; ScrollTrigger.refresh() after the
   reflow.

Motion 12 — Panels over a sticky background with a hover swap

A large image stays fixed in the background while white cards slide over it as you scroll: the image seems frozen in time behind the panels, which pass by like pages. And inside the categories panel, hovering over each name swaps the background image in a fade.

The lesson of this motion: the basic effect uses no JS. The background is position: sticky; top: 0; height: 100vh inside a tall wrapper; the panels are normal blocks with border-radius and a higher z-index that scroll over it. Stacking + sticky, and nothing else. The hover swap reuses the .now/.prev mechanism you will see in Motion 10.

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

1. Structure:
   <section class="showcase">           <!-- height: content -->
     <div class="showcase-bg">          <!-- sticky top-0 h-screen,
                                             z-index: 1 -->
       <div class="bg-layer now" data-target="slug-1">…img…</div>
       <div class="bg-layer" data-target="slug-2">…img…</div>
       <!-- 1 layer per category, all pre-mounted -->
     </div>
     <div class="showcase-panels">      <!-- z-index: 2, margin-top:
                                             -100vh to overlap -->
       <div class="panel">…title + feature…</div>
       <div class="panel">…category list…</div>
     </div>
   </section>
2. Panels: a white background, border-radius at the top
   (var(--r) var(--r) 0 0), height ~85-100vh each, content
   distributed (flex column space-between). The "passing over"
   effect is stacking + sticky alone, with no JS.
3. The hover swap: list items with data-target; mouseenter swaps
   .now/.prev on the bg-layers (the same crossfade CSS as Motion 10).
   With no active hover, keep the last category or the default one.
4. Internal panel reveals with Motion 3 (data-shown/useInView).
5. On clicking a category: navigate with the TransitionLink (Motion 2).
6. Mobile: hover exists in no form; the layers swap on scroll (which item
   in the list sits at the center) or stay on the default. Validate the cost of the
   mounted layers (cap N; lazy beyond the fold).

Motion 13 — Video expanding through clip-path

The video starts outside fullscreen: it appears as a cropped window with rounded corners inside the section. As you scroll, the crop opens without pause until the video takes the whole viewport: a camera iris coupled to the scroll. It is the Step 2 engine applied to a single property: clip-path: inset(... round ...) consuming --p1.

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

1. Structure: section[data-scroll-trigger data-start="top bottom"
   data-end="top top"] > div.video-full (height: 100vh) > video +
   an optional overlay.
2. The iris CSS (consuming --p1 from Motion 4; --p1 runs from 0 when the
   section enters to 1 when it lands at the top):
   .video-full {
     will-change: clip-path;
     clip-path: inset(
       calc(var(--p2) * 20%) calc(var(--p2) * 15%)
       calc(var(--p2) * 20%) calc(var(--p2) * 15%)
       round calc(var(--r) * var(--p2) * 4)
     );
   }
   (at --p1: 1 the inset zeroes out and the radius zeroes out: pure fullscreen)
   Toggle will-change while the section sits in the viewport alone.
   Closing on exit (optional): a second internal data-scroll-trigger
   element with data-start="bottom bottom" data-end="bottom top" and a
   second pair of vars applying the inverse inset.
3. Video: autoPlay muted loop playsInline preload="metadata" poster;
   webm sources before mp4; muted reinforced through a ref in useEffect
   (a known React issue) + .play().catch(() => {}).
   A lighter mobile version through <source media> or a matchMedia swap.
4. Pause outside the viewport (IntersectionObserver → video.pause()/play())
   to save battery and CPU.
5. prefers-reduced-motion: no iris (clip-path none, a static 16/9
   aspect-ratio) and the video paused showing the poster.

Step 8 — Desktop polish: contextual cursor and floating preview

A finishing layer, desktop only. In both cases the rule is the same: render on (hover: hover) and (pointer: fine) alone. On touch, the components return null and nothing breaks.

Motion 9 — Custom cursor with contextual labels

Beside the system cursor, a small dot follows the mouse with an elastic lag. Over a clickable image it gains the label "View"; over a zoomable one, "Zoom"; a blurred halo trails behind. The cursor becomes an affordance guide: it tells you what each area does before the click.

The idiomatic form of the movement is gsap.quickTo on x/y (the one justified exception to "CSS animates", since following the mouse does demand JS). And an accessibility rule the audited site respects: cursor: none in no case. The dot is a companion to the native cursor rather than a replacement.

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

1. A fixed structure (in the layout root): div.cursor (fixed, top-0 left-0,
   pointer-events-none, z-max) > div.cursor-body (the dot) +
   div.cursor-label[data-type=view] + div.cursor-label[data-type=zoom]
   + div.cursor-blur (a halo with filter: blur).
2. Movement: inside useGSAP,
   const xTo = gsap.quickTo(el, 'x', { duration: 0.4,
     ease: 'power3.out' });
   const yTo = gsap.quickTo(el, 'y', { duration: 0.4,
     ease: 'power3.out' });
   window mousemove → xTo(e.clientX); yTo(e.clientY).
   The blur halo uses a longer duration (0.8) to "drag" behind.
3. Context: a delegated mouseover/mouseout listener on the document;
   target.closest('[data-cursor="view"|"zoom"]') sets the active
   label (a class on the cursor); the labels enter and exit through the
   mask pattern of Motion 3.
4. Mark the areas across the site: listing thumbs → data-cursor="view";
   zoomable images (Motion 15) → data-cursor="zoom".
5. Render the component on (hover: hover) and (pointer: fine) alone
   (matchMedia inside a useEffect; return null on touch). NEVER hide the
   native cursor (cursor: none harms accessibility); the dot is a
   companion rather than a replacement.
6. prefers-reduced-motion: follow the mouse with no lerp (a direct position) or
   disable the component.

Motion 10 — Floating preview on list hover

A list of names, simple in appearance. On hovering over one, a thumbnail sprouts floating beside the cursor, with a light zoom-out as it settles. Slide to the next name and the image swaps in a crossfade. Running through the list becomes leafing through an invisible catalog.

The secret of the continuous crossfade is the .now/.prev class pair: the previous image stays visible underneath for an instant while the new one settles on top. And every thumbnail is pre-rendered and stacked from the start, so the hover waits on the network in no case.

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

1. Structure: div.popup (fixed top-0 left-0, pointer-events-none) >
   div.popup-frame (a fixed size, 200x200 for example, rounded, overflow
   hidden, opacity 0) > N divs.preview[data-target=slug], each one
   absolute inset-0 with next/image (all mounted from the start, so
   the hover waits on the network in no case).
2. The swap CSS (a crossfade with settling):
   .preview { opacity: 0; transform: scale(1.05);
              transition: opacity .3s, transform 0s; }
   .preview.now { opacity: 1; transform: scale(1); z-index: 2;
                  transition: opacity .3s, transform 1.2s; }
   .preview.prev { opacity: 0; transform: scale(1); z-index: 1;
                   transition: opacity 0s .3s, transform 0s 1.2s; }
   (the .prev holds the previous image visible underneath during the fade
   of the new one; that is what makes the swap look continuous)
3. Logic: mouseenter on a <li data-target=slug> → the matching
   preview becomes .now, the old one becomes .prev; mouseenter on the list →
   frame opacity 1; mouseleave from the list → opacity 0 (after the transition,
   clear .now/.prev).
4. Movement: follow the mouse with gsap.quickTo (an offset of ~20px from the
   cursor, duration 0.5, power3.out).
5. Desktop only ((hover: hover)); on touch the list works with no
   preview. The items stay linkable and keyboard-accessible
   (the preview is decorative, aria-hidden).

The most interdependent set on the site. Implement the three together.

Motion 14 — Split-scroll with a fixed aside

The project page splits in two: on the right, a panel with the title, the credits and the description stays put; on the left, the photo column slides up the page as you scroll, with the active photo highlighted and a circular progress indicator in the corner. The sensation of leafing through the portfolio with one hand while the credits stay in view.

The Step 2 engine again: the tall wrapper defines the scroll length, the media column is position: fixed moved by transform: translateY(calc(-50% * var(--p1))), and even the circular indicator is the same var: a stroke-dashoffset calculated from --p2 in an SVG.

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

1. Structure:
   <div class="detail" data-scroll-trigger data-start="top top"
        data-end="bottom bottom">        <!-- height: N * space/photo -->
     <div class="media-col">             <!-- fixed left, w-1/2 -->
       <ul>…N figures (images/videos)…</ul>
     </div>
     <aside class="info">…title, credits (dl), description…</aside>
                                         <!-- fixed right, w-1/2,
                                              h-screen -->
     <div class="progress">…circular indicator…</div>
   </div>
2. CSS: .media-col { position: fixed; top: 0; height: 100%;
   transform: translateY(calc(-50% * var(--p1))); }
   (the list runs twice the viewport height; --p1 covers
   the whole path). Circular indicator: stroke-dashoffset =
   calc(circumference * var(--p2)) in an SVG.
3. The active item: derive it from --p1 (the trigger's onUpdate already runs; in an
   additional callback, compute the index and apply the ._on class to the item:
   the placeholder background disappears, the "Zoom" cursor turns on).
4. The aside is fixed with pointer-events: none on the wrapper and auto on the
   interactive parts (titles, links, the copy-link button), so the scroll
   "passes through" the panel.
5. A "copy link" button with a Copy → Copied label flip (the Motion 8
   pattern; a temporary _ok state through the clipboard API).
6. Mobile: disable the split layout (a relative media-col, the aside after the
   content, the progress hidden), through CSS/matchMedia; it is the same fallback
   as the audited site.
7. Integrate with Motion 15 (zoom) and finish with Motion 16
   (horizontal related items).

Motion 15 — Click-to-zoom on an image (FLIP)

Click a photo and it grows with a smooth move from where it sits to the center of the screen, while the rest of the page darkens and the scroll locks. Clicking again sends the photo back, shrinking into the exact hole it came from. No blinking modal: it is the image itself traveling.

The name of the technique is FLIP (First-Last-Invert-Play): measure the current rectangle, compute the transform to the final rectangle and animate transform alone. GSAP's Flip plugin (free) does that out of the box.

txt
Implement click-to-zoom in Next.js with GSAP Flip:

1. Register the Flip plugin in lib/gsap.ts. Wrap zoomable images
   in <figure class="zoomable" data-cursor="zoom">.
2. On click:
   - const state = Flip.getState(figure);
   - Apply the 'is-zoomed' class (the CSS positions the figure fixed,
     centered, max 90vw/90vh, object-fit contain, a high z-index);
   - Flip.from(state, { duration: 0.8, ease: 'power4.inOut',
     absolute: true });
   - Mark <html> with 'is-zoom-in': the CSS applies opacity .3 and
     pointer-events: none to the rest; a fixed backdrop captures the
     click to close; lenis.stop().
3. Close (click/Escape): Flip back to the original state
   (getState of the zoomed one → remove the classes → Flip.from), then
   lenis.start() and focus returned to the figure.
4. Load the @2x version of the image on zoom (swap the src in an
   onStart; keep the @1x until the new one decodes so it fails to blink).
5. Accessibility: role="button" + aria-expanded on the figure, Escape
   closes, focus managed; prefers-reduced-motion swaps the FLIP for a
   quick fade.

Motion 16 — Horizontal list with drag

At the end of the detail page, a row of cards runs from side to side: drag with the mouse or a finger and the cards slide. It is the final shelf that joins one navigation to the next.

The option faithful to the audited site needs no libraries: a container with overflow-x: auto, a hidden scrollbar, drag-to-scroll with Pointer Events converting deltaX into scrollLeft, and data-lenis-prevent so the global Lenis leaves the gesture alone.

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

1. Structure: div.row (overflow-x auto, a scrollbar hidden through
   scrollbar-width: none and ::-webkit-scrollbar { display: none },
   data-lenis-prevent) > a flex ul with N cards (flex 0 0 auto,
   width ~40vw desktop / 80vw mobile, gap).
2. Drag-to-scroll with Pointer Events:
   pointerdown → store startX/scrollLeft and setPointerCapture;
   pointermove → row.scrollLeft = scrollLeft - (x - startX);
   pointerup → release. During the drag, an 'is-dragging' class
   (cursor grabbing) and suppress the click if movement went past 8px
   (preventDefault on the subsequent click).
3. Inertia: the native scroll gives momentum on touch already; for the mouse,
   apply a simple decay on pointerup (velocity * friction through
   requestAnimationFrame) or accept the hard stop (acceptable).
4. Cards: a thumb with hover zoom (Motion 11), a title with Motion 3, the
   "View" cursor (Motion 9). The first and last item with lateral padding
   var(--go) for breathing room.
5. Accessibility: the list is Tab-navigable in the normal way (the native
   scroll follows the focus); keyboard arrows optional.
6. If the design asks for snap/pagination/autoplay: swap in Embla
   Carousel and keep the rest the same.

Step 10 — Infinite scroll in the galleries (Motion 6)

After the detail page, it gains a rare superpower: in a single item's galleries, the scroll ends in no place. Reached the last image? The first reappears spliced on, in an infinite loop, like a continuous roll of fabric.

The technique: a second Lenis instance with infinite: true, mounted in a wrapper of its own, with the content duplicated so the loop closes. And one care point that stays mandatory: infinite scroll is hostile to keyboards and screen readers, so the static-list fallback has to exist.

txt
Implement gallery infinite scroll in Next.js:

1. A client component "InfiniteGallery" used on the detail routes:
   - Structure: div.wrapper (viewport, overflow hidden) > div.content
     holding the DUPLICATED image list (two identical copies,
     so the loop splices).
   - const lenis = new Lenis({ lerp: 0.1, wrapper: wrapperRef.current,
     content: contentRef.current, infinite: true });
   - Its own rAF (gsap.ticker.add with a named function) and cleanup in
     3 steps as in Prompt 0. PAUSE the page's global Lenis
     (useLenis().stop()) while this instance owns the scroll,
     and restore it on unmount.
2. In the lenis.on('scroll') callback, normalize the progress
   (scroll % heightOfOneCopy) and write --p1 on the content for effects
   that depend on position (a highlighted center item, for example).
3. A fixed circular progress bar or indicator reading the same progress.
4. Accessibility: infinite scroll confuses keyboard navigation;
   offer an alternative static list (prefers-reduced-motion and
   Tab navigation disable infinite mode, falling back to normal
   scroll with the single list).
5. Mobile: validate the touch behavior; if needed, disable
   infinite and use native scroll (gsap.matchMedia).

Motion 17 — Decorative sprites (pop + parallax layers)

On the institutional page, graphic elements of the brand sprout on screen. In Plantica's case, actual flowers: each one pulls itself out of nothing with an elastic pop (born at zero scale and settling at its final size), in different positions and at different times, a garden blooming in stop motion around the text. Others stay in background layers moving at different speeds during the scroll, giving a diorama's depth.

The performance insight: it all comes from sprite sheets, a single image holding every cutout, each element displayed through background-position with CSS vars. Dozens of decorative elements, a single request.

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

1. Generate the sprite sheet of the elements (one .webp with every cutout)
   and a manifest JSON: [{ name, fx, fy, w, h, x, y, layer,
   delay }] (the position in the sheet + the position/layer on screen).
2. Component:
   <div class="sprite" data-type="pop|bg" data-scroll-trigger>
     {frames.map(f =>
       <div class="frame" style={{ '--fx': f.fx, '--fy': f.fy,
            '--sw': SW, '--sh': SH, '--w': f.w, '--h': f.h,
            left: f.x, top: f.y, '--d': `${f.delay}s` }}>
         <div class="img" />
       </div>)}
   </div>
   CSS: .frame .img { width: calc(var(--w) * 1px);
     height: calc(var(--h) * 1px);
     background-image: url(sprite.webp);
     background-position: calc(var(--fx) * -1px) calc(var(--fy) * -1px);
     background-size: calc(var(--sw) * 1px) calc(var(--sh) * 1px); }
   (on the audited site the math uses the vw unit to scale with the screen;
   parameterize the unit)
3. The pop type: .img { transform: scale(0); transition: transform 1.8s
   var(--d) var(--ease-power4-out) } → [data-shown] .img
   { transform: scale(1) } (fire it with useInView; the long ease-out
   gives the "elastic" settling).
4. The bg type: each layer in a wrapper with
   transform: translateY(calc(var(--p1) * var(--depth) * -10vh))
   (depth 0.3/0.6/1.0 per layer), consuming the Motion 4 engine.
5. Every frame with pointer-events: none and aria-hidden (decoration).
6. prefers-reduced-motion: the pops enter at scale(1) already with a fade;
   parallax frozen.

Three acts in the same place. First, the footer scrolls into view in no way: it gets revealed, since the page ends and the content slides upward like a curtain, showing the footer that already sat below, moving at a reduced speed. Second, an invitation band slides along without pause, in the style of a marquee sign. Third, behind the band, a blurred blot of color follows the mouse like light crossing frosted glass, a WebGL canvas of a few KB.

Note that each act uses the minimum tool needed: the reveal is the same scroll engine as always; the marquee is pure CSS in a Server Component, zero JS; the light blot alone justifies WebGL.

txt
Create the composite footer in Next.js (3 sub-components):

1. "FooterReveal": a footer with
   <div class="footer-body" data-scroll-trigger data-start="top bottom"
        data-end="bottom center">…content…</div>
   CSS: .footer-body { height: 100vh;
     transform: translateY(calc(var(--p2) * -50vh)); }
   (it enters "late" and reaches its natural position when the scroll
   ends: the curtain effect revealing it). The page body with a z-index
   above the footer and a solid background.
2. "InviteMarquee" (a Server Component, zero JS):
   div.mq (overflow hidden, flex) > ul.mq-ul (flex, width: 200%) >
   2 identical li.mq-li (each one with the phrase repeated K times);
   CSS: .mq-li { will-change: transform;
     animation: marquee 30s linear infinite; }
   @keyframes marquee { from { transform: translateX(0) }
     to { transform: translateX(-100%) } }
   (with TWO items of 100% each, the -100% splices perfectly)
   The whole marquee is a link to the contact page; an sr-only
   version of the text outside the band; animation: none under
   prefers-reduced-motion; an optional pause on hover.
3. "MouseBlurCanvas" (client): an absolute canvas behind the marquee
   (pointer-events: none), with OGL:
   - A fullscreen plane + fragment shader: 2-3 radial gradients of
     brand colors summed, the center offset by u_mouse (a uniform
     vec2) smoothed with a lerp in the rAF, u_time for slow breathing.
   - A resize observer updates the resolution; renderer.dispose() and
     cancellation of the rAF in the cleanup.
   - Fallback with no WebGL: a static CSS gradient.
   - Render while the footer sits in the viewport alone
     (an IntersectionObserver turns the rAF on and off).
4. Round it out with sitemap columns (links with Motion 8), a large logo
   with a reveal (Motion 3) and credits.

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

Each item on this list cost someone hours of debugging. Go through it before writing the first line, and come back when something breaks with no visible cause.

  • "use client" is mandatory in every component that touches GSAP, Lenis or observers.
  • Always useGSAP({ scope }) in place of a raw useEffect: the automatic context.revert() kills orphan triggers when you navigate between routes.
  • The Lenis cleanup takes 3 steps: gsap.ticker.remove(update) + lenis.off('scroll', ...) + lenis.destroy(). It holds for the global instance and for the local ones (the infinite gallery).
  • Locking the scroll with Lenis: use lenis.stop()/start() (menu, zoom) rather than overflow: hidden alone; and data-lenis-prevent on areas with a scroll of their own.
  • CSS vars + SSR: initialize --p1: 0; --p2: 1 in the CSS, rather than through JS alone, or a flash appears on the first paint.
  • transform, opacity and clip-path alone in the CSS-var animations; animate width, height, top or left in no case (layout thrashing on every scroll frame).
  • Surgical will-change: on the elements that animate alone, toggled when the section is visible for preference; a global will-change degrades GPU memory.
  • ScrollTrigger.refresh() after images and fonts load and after each route transition; positions computed before the final layout come out wrong.
  • Split text after the fonts alone (autoSplit: true or document.fonts.ready); and always aria-label on the parent + aria-hidden on the spans.
  • gsap.matchMedia() for responsive trigger variations; GSAP deprecated ScrollTrigger.matchMedia back in 3.11.
  • Global states on <html> (is-loaded, is-leaving, is-menu-opened, is-zoom-in): centralize them in a small store that mirrors the classes; never scatter ownerless toggles.
  • Custom cursor and previews: desktop only ((hover: hover) and (pointer: fine)); on touch, the components return null. And cursor: none in no case.
  • WebGL: always dispose (renderer, geometries, textures) and cancel the rAF on unmount; render with the canvas in the viewport alone.
  • Infinite scroll is hostile to keyboards and screen readers: always offer the static-list fallback.
  • prefers-reduced-motion on everything: the CSS-first pattern makes it easier, since a single media query block can neutralize transitions and keyframes across the board.

Where to start today

If the whole guide seems like a lot, start with the trio that pays back the investment fastest: Step 1 (the foundation, without which nothing works), Step 2 (the --p1/--p2 engine, half an hour of work that unlocks six motions) and Motion 8 (buttons and underlines, pure CSS, an immediate result, used all over).

With those three in place, each following motion is incremental: you hold the states, the engine and the language already. It also pays to open plantica.net in a tab beside you and scroll slow, recognizing each pattern from this guide in action in the original. No better exercise exists for training the eye.

And the philosophy stays in the muscle: the JS writes variables and classes; the CSS does the movement. That, more than any library, is what separates the sites that look premium from the ones carrying animation and nothing else.