Skip to content
Zumkai

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

The hybrid architecture of the ANAI site, with CSS vars, per-section tweens and canvas, broken down into 18 motion patterns with ready-made prompts.

  • motion
  • gsap
The ANAI home page: the serif headline "Cultivate for Posterity" in giant letters on a white background, with the forest image window opening below
Contents
  1. The philosophy: three layers, each one in its place
  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 double engine: --pg-value on scroll, --x/--y on the mouse (Motion 4)
  6. Step 3 — The typography that lights up: scramble and sweep (Motion 5)
  7. Step 4 — Global states: preloader, transitions and the smart header
  8. Step 5 — A quick win: underlines with a swapped origin and arrows that breathe (Motion 9)
  9. Step 6 — The calling card: the window that opens and the lens flare
  10. Step 7 — Series production: clips, a counter-parallax wall and the footer
  11. Step 8 — UI identity: the menu with living dots and the two-layer cursor
  12. Step 9 — The first slider: a showcase with a central title that swaps (Motion 12)
  13. Step 10 — The crown jewel: a slider synced with rings on canvas (Motion 13)
  14. Step 11 — Quick polish: 3D image tilt driven by the mouse (Motion 8)
  15. Step 12 — The "wow" layers: WebGL particles and DOM physics
  16. The 18 traps that will bite you (read before coding)
  17. Where to start today

This is the second reverse-engineering study in the series. In the Plantica guide, the secret was radical: the JS animated close to nothing, writing variables and classes for the CSS to move. The site this time answers the same question (how does a real premium site organize its animations?) with a different architecture, and the contrast between the two is the best motion lesson you will get this year.

The audited site this round is ANAI (anaiwood.com): the Anai Wood Factory, a Japanese sawmill founded in 1964 that introduces itself with the motto "We Cultivate for Posterity", and with a site to match, built in Nuxt/Vue with GSAP, SplitText, Three.js and Matter.js. From the preloader with a counter to the wood growth rings drawn on canvas, everything there carries intent. An audit of that site produced the 18 patterns below, translated into the Next.js ecosystem; names, texts and images are parameterizable placeholders. You take away the movement pattern, the technique and an implementation prompt ready to paste into your AI agent or to use as a specification.

As in the previous guide, the order of the steps departs from the order of the catalog: it is the order of construction, from the groundwork to the sugar.

The philosophy: three layers, each one in its place

Plantica was "CSS-first, JS-orchestrated". ANAI is a hybrid architecture in three layers, and understanding the border between them means understanding the whole site:

  • Layer 1, continuous variables. A scroll engine writes each section's progress into a CSS custom property (--pg-value), and a global tracker writes the mouse position into two vars on <html> (--x/--y, with a lerp). The CSS consumes it all with calc(): clips, parallax, tilts and flares run on the compositor, with no frame of JS.
  • Layer 2, entrance tweens. A central section observer fires one-off GSAP animations when each block enters the viewport: fades, paragraph sweeps and the color scramble of the titles. They are one-shot tweens: arrival choreography rather than scroll choreography.
  • Layer 3, isolated canvas. The "wow" moments live on canvases of their own: concentric rings in 2D synced to the slider, ambient particles in WebGL and element physics with Matter.js. All optional, all degradable, and none of them contaminates the other layers.

Tying it all together, the global states travel through classes on <html>: --is-loading, --is-transition, --is-scrolled, --is-menu-active. The whole CSS reacts to them.

The stack and the role of each piece

LibraryRole in the projectWhy
GSAP 3.13+ and @gsap/reactPer-section entrance tweens, text scramble (SplitText), preloader timelinesGSAP is 100% free, every plugin included, since the Webflow acquisition
ScrollTrigger (a GSAP plugin)Writing --pg-value per section (it replaces the audited site's own scroll engine)The same result with less proprietary code; a short scrub + Lenis reproduce the feel
LenisSmooth scroll with a lerp on desktop (touch stays native, the same decision as the audited site)It delivers scroll, velocity and direction ready to use, and they feed the smart header
SplitText (a GSAP plugin)Splitting titles into chars for the color reveal with a random staggerThe typographic signature of the audited site
Three.js (or OGL)Ambient particles (Points + noise in the vertex shader)A "living" decorative layer over atmospheric images
Matter.jsPhysics for micro DOM elements (synced bodies + a cursor body)Playful micro-interactions at a low cost
CSS custom properties and transitionsClips, tilts, parallax and underlines consuming --pg-value/--x/--yScroll and mouse animations run on the compositor, with no re-render

You need no carousel plugin: the audited site's sliders are homemade (translate + state). If you prefer an abstraction, Embla Carousel covers both sliders in this guide.

The map of the 18 motions

#MotionTypical useTrigger
1Preloader with progress + handoffFirst loadLoad
2Route transition with a maskEvery internal navigationNavigation
3Smooth scroll + a smart headerGlobalScroll
4Progress engine → CSS vars + global mouseThe base of motions 6, 7, 8, 14, 15, 18Scroll/Mouse
5Text: color scramble + sweepTitles and paragraphsInview
6Hero: a clip window that opensThe home heroLoad/Scroll
7Layered lens flare driven by the mouseHero/atmospheresMouse
83D image tilt driven by the mouseEditorial imagesMouse/Hover
9Underlines and arrows with micro-animationLinks and navigationsHover
10Fullscreen menu with a "living" buttonGlobal headerClick
11Custom cursor in 2 layersDesktopMouse
12Showcase slider with a central titleThe showcase on the homeHover/Click
13Slider synced with rings on canvasThe project showcaseClick/Drag
14Columns in counter-parallaxAn immersive thematic sectionScroll
15Media clip reveals by scrollSubpages and galleriesScroll
16Ambient WebGL particlesAtmospheres/heroAutomatic
17Physics for DOM elementsPlayful micro-interactionsMouse
18Footer with a parallax settleGlobal footerScroll

Generic section reveals (fades with autoAlpha, sweeps, scrambles) come from the Motion 5 section observer and carry no number of their own.

Step 1 — The foundation: animation infrastructure

Everything depends on this step, and it reveals the architecture decisions already: Lenis enters on non-touch devices alone (on the phone, native scroll, as it should be), the global states gain a store with a single owner, and the mouse tracker is born here because half a dozen motions will drink from the same source.

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

1. Install: gsap @gsap/react lenis three matter-js
   (+ @types/matter-js and @types/three in devDependencies)
2. Create a client component "SmoothScrollProvider" that:
   - Instantiates Lenis with { lerp: 0.1, smoothWheel: true } on
     non-touch devices alone (matchMedia '(pointer: fine)'); on touch,
     native scroll (the same decision as the audited site).
   - Syncs with ScrollTrigger using a named function:
       const update = (time: number) => lenis.raf(time * 1000);
       lenis.on('scroll', ScrollTrigger.update);
       gsap.ticker.add(update);
       gsap.ticker.lagSmoothing(0);
   - COMPLETE cleanup on unmount (in this order):
       gsap.ticker.remove(update);   // without it the ticker keeps calling
       lenis.off('scroll', ScrollTrigger.update);   // raf() on a destroyed
       lenis.destroy();                             // instance (leak)
   - Expose the instance through Context (useLenis) for stop()/start()
     (menu, preloader, zoom).
3. Register the plugins once in lib/gsap.ts:
   gsap.registerPlugin(ScrollTrigger, SplitText, useGSAP); export gsap.
   Imported by client components alone.
4. Create the per-section progress engine (Motion 4): the hook
   useSectionProgress that writes --pg-value on each [data-progress].
5. Create the global mouse tracker (Motion 4): pointermove with a lerp
   inside a rAF writing --x and --y (normalized from -1 to 1) on <html>.
6. Create a small global state store (Zustand or Context) that
   mirrors classes on <html>: --is-loading, --is-transition,
   --is-scrolled, --is-menu-active, and the attribute
   data-scroll-direction ('up' | 'down').
7. A usePrefersReducedMotion() hook reading the media query
   (prefers-reduced-motion: reduce); every animation component should
   degrade when it is true.
8. Tokens in globals.css (the audited site uses beziers of its own):
   :root {
     --ease-soft-out: cubic-bezier(0.104, 0.204, 0.492, 1);
     --ease-snap-out: cubic-bezier(0.306, 0.968, 0.632, 1);
     --ease-in-out-strong: cubic-bezier(0.642, 0, 0.328, 1);
     --dur-ui: 0.35s; --dur-reveal: 1s;
     --x: 0; --y: 0;      /* the global mouse, written by the tracker */
   }
Deliver the complete files and the updated layout.tsx.

Step 2 — The double engine: --pg-value on scroll, --x/--y on the mouse (Motion 4)

The heart of Layer 1, invisible by definition. Everything that moves with the scroll reads its own section's progress from a variable; everything that reacts to the mouse reads the pointer position from two others, with an elastic lag. That is why the site's effects all look like siblings: they are born from the same two sources.

Two writers, zero React re-render. And two professional details: initializing the vars in CSS (the server HTML comes out in the right state, with no flash) and writing with restraint, with toFixed(4) and a minimum variation threshold before touching the DOM. Writing a var every frame with no real change is pure cost.

txt
Create the double var engine in Next.js:

1. A client hook "useSectionProgress(scopeRef)" inside useGSAP:
   gsap.utils.toArray('[data-progress]', scope).forEach((el) => {
     ScrollTrigger.create({
       trigger: el,
       start: el.dataset.start ?? 'top bottom',
       end: el.dataset.end ?? 'bottom top',
       scrub: 0.05,
       onUpdate: (self) => el.style.setProperty(
         '--pg-value', self.progress.toFixed(4))
     });
   });
   Initialize it in the CSS: :where([data-progress]) { --pg-value: 0 }
   (the SSR renders the initial state with no flash).
2. The mouse tracker (client, in the root layout):
   - pointermove stores the normalized target
     tx = (e.clientX / innerWidth) * 2 - 1 (the same for ty);
   - inside a gsap.ticker.add, do x += (tx - x) * 0.08 and write
     document.documentElement.style.setProperty('--x', x.toFixed(4))
     (the same for --y). Write when the variation passes 0.0005 alone.
   - Disable it on (pointer: coarse) and under prefers-reduced-motion
     (freeze --x/--y at 0).
3. Consumption in the CSS (examples):
   .col   { transform: translateY(calc((1 - var(--pg-value)) *
            var(--dist))); }
   .clip  { clip-path: inset(calc((1 - var(--pg-value)) * 15%)); }
   .tilt  { transform: rotateX(calc(var(--y) * -10deg))
            rotateY(calc(var(--x) * 10deg)); }
4. will-change on the elements that animate alone, and toggled by
   visibility: an IntersectionObserver marks data-inview="true" and the
   CSS turns will-change on in that state alone (GPU hygiene identical to the
   audited site's).
5. ScrollTrigger.refresh() after images/fonts load and after route
   transitions.

Step 3 — The typography that lights up: scramble and sweep (Motion 5)

The ANAI typographic signature neither rises nor slides: it lights up. Each letter of the title starts in a faded color and turns the final color in random order, like the bulbs of a sign switching on out of sequence, until the whole word is lit. On the paragraphs, a sweep: the block gets revealed as if an invisible highlighter passed from left to right.

This is the heart of Layer 2, and the step also installs the central section observer that every entrance motion will reuse (a single IntersectionObserver for the whole page rather than one per component).

txt
Create the typographic reveal system in Next.js:

1. A central observer: the hook "useSectionObserver" with the API
   observer.add(sectionEl, callback) using an IntersectionObserver
   (threshold 0.2, once by default); every entrance motion registers
   with it (a single IO for the whole page).
2. A "ScrambleTitle" component:
   - SplitText.create(el, { type: 'chars', autoSplit: true,
     onSplit: (self) => gsap.set(self.chars, { color: 'var(--col-dim)' }) })
     (autoSplit awaits the fonts; without it the breaks come out wrong);
   - in the observer callback:
     gsap.fromTo(split.chars, { color: cssVar('--col-dim') },
       { duration: 1, color: cssVar('--col-ink'), ease: 'power1.out',
         stagger: { amount: 0.5, from: 'random' } });
   - from/to props override the colors (useful over dark backgrounds);
   - aria-label with the text on the parent + aria-hidden on the chars.
3. A "SweepParagraph" component:
   - CSS: color: transparent;
     background-image: linear-gradient(90deg, var(--col-ink) 50%,
       var(--col-dim) 50%);
     background-size: 200% 100%; -webkit-background-clip: text;
     background-clip: text; background-position: 100% 100%;
   - in the observer: gsap.to(el, { duration: 1,
     backgroundPosition: '0% 0%', ease: 'power1.out' });
4. Standard section entrance complements (the same callback):
   gsap.fromTo(targets, { autoAlpha: 0 }, { autoAlpha: 1,
   duration: 0.5–1, a staggered delay of 0/0.1/0.2 }).
5. prefers-reduced-motion: colors and backgrounds in the final state already, with no tween.

Step 4 — Global states: preloader, transitions and the smart header

Three motions that establish --is-loading, --is-transition and --is-scrolled, the classes the whole CSS references.

Motion 1 — Preloader with progress and a choreographed handoff

Before any content, the screen shows the centered logo and a discreet counter. While the heavy images load, the number climbs; on reaching 100, the preloader does more than disappear: it hands over the stage, since the logo retracts, the curtain leaves and the hero enters animating already. Loading and first scene become a single choreography. (Visit anaiwood.com and note it: it is the first thing you see, and it is this post's cover.)

The honest detail: the progress is real, built from Promises of the critical assets, with the number interpolated through GSAP so it jumps and regresses in no case. And the hero animates before the onComplete signal in no case.

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

1. A fixed inset-0 z-[999] overlay with a solid background, a central logo (SVG) and
   a counter (00 → 100). While active: the '--is-loading' class on <html>
   and lenis.stop().
2. Real progress: a partial Promise.all over the critical assets
   (document.fonts.ready + decode() of the above-the-fold images);
   each resolved asset increments the target. Animate a proxy object with
   gsap.to({ v }, { v: target, duration: 0.6, ease: 'power1.out',
   onUpdate }) so the number climbs with a smooth move, with no jumps or regressions.
3. The exit (a single timeline):
   - counter and logo: autoAlpha 0 + a subtle y, 0.5s;
   - the preloader panel: autoAlpha 0 (or a curtain clip-path), 0.6s;
   - onComplete: remove '--is-loading', call lenis.start() and fire
     the event/callback that starts the hero entrance (Motion 6); the
     hero animates before that signal in NO case.
4. Run it once per session (sessionStorage); on other visits,
   skip straight to the hero entrance signal.
5. Accessibility: role="status" aria-live="polite" on the counter;
   prefers-reduced-motion shortens it all to a quick fade.

Motion 2 — Route transition with a darkening mask

On clicking a link, the current page darkens under a translucent veil and slides with a subtle move, as if it stepped back; the new one enters over it, firing its reveals already. And a rare refinement: going "forward" and coming "back", the directions of the movement invert, so the spatial hierarchy of the navigation becomes legible in the motion itself.

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

1. A TransitionProvider (client) with navigate(href, direction):
   - It sets '--is-transition' on <html> (CSS: .--is-transition .l-all
     { cursor: wait }) and pauses content interactions.
   - It animates the exit of the current view: the '--is-previous' class +
     gsap.to(currentView, { autoAlpha: 0.999, y: direction === 'forward'
     ? -24 : 24, duration: 0.45, ease: 'power1.inOut' }) in parallel
     with the mask: a div.transition-mask (absolute inset-0,
     background rgba(0,0,0,0.5), pointer-events none) from opacity 0 → 1.
   - At the end: router.push(href).
2. A TransitionLink wrapping next/link (prefetch preserved) that calls
   navigate; "back" links pass direction 'backward'.
3. Entrance: template.tsx remounts on each route; inside it remove
   '--is-transition', do lenis.scrollTo(0, { immediate: true }),
   animate the container from opacity 0 → 1 (0.35s) and fire the page's
   entrance reveals (Motion 5/6). ScrollTrigger.refresh() after the
   images load.
4. popstate (the browser back button): skip the exit phase and use the
   entrance alone.
5. prefers-reduced-motion: cut to short fades with no displacement.

Motion 3 — Smooth scroll + a smart header driven by direction

Scrolling with buttery inertia on desktop (native on the phone). And a clever header: scrolling down, it retracts to give the screen back to the content; at the first upward gesture, it returns at once. The whole site knows which direction you are going, since Lenis delivers direction for free and the header reacts with CSS alone.

txt
Implement the smart header in Next.js:

1. In the SmoothScrollProvider, subscribe to lenis.on('scroll', ({ scroll,
   direction }) => ...):
   - scroll > THRESHOLD (80px for example) → the '--is-scrolled' class on <html>;
     below the threshold, remove it.
   - direction (1 down, -1 up) → the attribute
     data-scroll-direction="down"|"up" (update it on a change alone,
     so the DOM stays untouched every frame).
   On touch (with no Lenis), derive it from window.scrollY deltas in a
   passive listener with rAF.
2. Header CSS (fixed top, a high z-index):
   .header-title { transition: opacity var(--dur-ui) var(--ease-soft-out),
     visibility var(--dur-ui), transform var(--dur-ui)
     var(--ease-snap-out); }
   .--is-scrolled[data-scroll-direction="down"] .header-title {
     opacity: 0; visibility: hidden; pointer-events: none;
     transform: translateY(calc(var(--header-h) / -10)); }
3. The header hides with the menu open in no case ('--is-menu-active'
   takes precedence) and during '--is-transition' in no case.
4. Internal anchors: lenis.scrollTo(target, { offset: -headerH }).

Step 5 — A quick win: underlines with a swapped origin and arrows that breathe (Motion 9)

Two classics executed with surgical precision, 100% CSS. On the underline, the refinement is in the exit: on taking the mouse away, the line retreats in no way and carries on, leaving through the other side, as if it crossed the link. The eye reads continuity rather than a return. The trick is a transform-origin swap between the states. On the arrows, the icon shoots out in the direction it points and re-enters from behind in the same instant: a breath that confirms the direction of the movement.

txt
Create the "u-underline" and "ArrowButton" utilities in Next.js:

1. An underline with a swapped origin (a utility class):
   .u-underline { position: relative; }
   .u-underline::before { content: ''; position: absolute; bottom: 0;
     left: 0; width: 100%; height: 1px; background: currentColor;
     transform: scaleX(0); transform-origin: 100% 50%;
     transition: transform .4s var(--ease-soft-out); }
   .u-underline:hover::before { transform: scaleX(1);
     transform-origin: 0 50%; }
   (the origin swap makes the line enter from the left and leave through the
   right; invert the origins for the opposite direction)
2. ArrowButton (slider/link arrows):
   @keyframes arrow-out { to { transform: translateX(120%);
     opacity: 0; } }
   @keyframes arrow-in { from { transform: translateX(-120%);
     opacity: 0; } to { transform: translateX(0); opacity: 1; } }
   .arrow-btn:hover .arrow-svg { animation: arrow-out .2s
     var(--ease-in-out-strong) forwards,
     arrow-in .3s .2s var(--ease-snap-out) forwards; }
   The backward variant mirrors the signs; the external variant (links that
   open outside) animates on the diagonal (translate(120%, -120%)).
   A container with overflow: hidden.
3. Disabled arrow states (the end of a slider): opacity .3 +
   pointer-events none.
4. focus-visible with a ring at all times; hover under @media (hover: hover) alone.

Step 6 — The calling card: the window that opens and the lens flare

The hero validates the double engine from Step 2, with the two vars working together in the same fold.

Motion 6 — Hero: an image window that opens

The first frame is an atmospheric photograph framed by empty margins: a window cut into the center of the screen. Over it, the title lights up (Motion 5) and a scroll invitation pulses. On scrolling, the window opens: the margins collapse until the image fills everything, while the photo settles from 105% to 100% scale. The book cover opening to the first page.

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

1. Structure:
   <section class="hero" data-progress data-start="top top"
            data-end="bottom top">          <!-- height: ~180vh -->
     <div class="hero-sticky">              <!-- sticky top-0 h-screen -->
       <div class="hero-clip">              <!-- the window -->
         <img class="hero-img" ... />
       </div>
       <h1 class="hero-ttl">…ScrambleTitle across 2 lines…</h1>
       <div class="hero-scroll">…the scroll indicator…</div>
     </div>
   </section>
2. CSS:
   .hero-clip {
     --clip-h: calc((1 - var(--pg-value)) * var(--clip-h-max, 12vh));
     --clip-w: calc((1 - var(--pg-value)) * var(--clip-w-max, 10vw));
     clip-path: inset(var(--clip-h) var(--clip-w));
     height: 100svh;
   }
   .hero-img { transform: scale(calc(1.05 - var(--pg-value) * 0.05));
               object-fit: cover; }
   Expect: --pg-value 0 = the window closed at the maximum margins;
   1 = fullscreen. Adjust the maximums per breakpoint.
3. The post-preloader entrance (the Motion 1 signal): animate --clip-h-max/-w-max
   from larger values (a small window) to the final ones with a gsap.to on the
   element (CSS vars are tweenable by GSAP), in parallel with the
   h1's ScrambleTitle and the fade of the scroll indicator.
4. The scroll indicator: a label + an arrow with the Motion 9 arrow keyframe
   in a smooth loop; it disappears along with the progress
   (opacity: calc(1 - var(--pg-value) * 2)).
5. The image with next/image (priority, sizes, a blur placeholder using the
   blurred preload version).
6. prefers-reduced-motion: the window open already, with no scale.

Motion 7 — Layered lens flare following the mouse

Over the hero photo lives a lens reflection: a blurred disc of light, a polygonal fragment and a tertiary glow, the way it looks when you shoot against the sun. On moving the mouse, the layers drift at different speeds, the way a real flare does when the camera turns. And the cost? Zero JS of its own: they are divs with blur and clip-path consuming the global mouse vars at different factors. Subtle, close to subliminal, perceived before it gets noticed.

txt
Create a "LensFlare" component in Next.js (a Server Component: CSS alone):

1. A <div class="flare" aria-hidden="true"> with 3 absolute sub-layers
   positioned over the light point of the photo:
   .flare-1 { width: 10vw; height: 10vw; border-radius: 50%;
     background: rgb(64 64 64 / .1); filter: blur(2px);
     transform: translate(-50%, calc(var(--x) * 15px)); z-index: 1; }
   .flare-2 { width: 4vw; height: 4vw;
     clip-path: polygon(50% 0, 90% 20%, 100% 60%, 75% 100%,
       25% 100%, 0 60%, 10% 20%);      /* a heptagonal fragment */
     background: rgb(255 255 255 / .1);
     transform: translate(-50%, calc(var(--x) * 25px)); z-index: 3; }
   .flare-3 { …a smaller variation with a 40px factor… }
2. Growing factors per layer (15/25/40px) create the differential
   drift; add var(--y) with smaller factors if you want vertical
   drift too.
3. will-change: transform on the layers; pointer-events: none on the block.
4. On (pointer: coarse) the vars stay at 0 (Motion 4) and the flare stays
   static: correct behavior with no extra code.
5. Keep the opacities low (≤ 0.12): the effect should be perceived
   before it gets noticed.

Three motions on the same base. Once the foundation is right, each new section costs minutes.

Motion 15 — Media clip reveals by scroll

The hero vocabulary comes back at a smaller scale across the whole site: images that enter opening their frames as the scroll reaches each one. The consistency makes the site look built from a single material, and it is the same pair as always: clip-path + a scale settle, consuming the local --pg-value.

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

1. Props: as: 'img' | 'video', inset (the maximum percentage, default 15%),
   axis: 'both' | 'x' | 'y', range ({ start, end } of the trigger).
2. Structure: <figure class="clip-media" data-progress
   data-start="top bottom" data-end="center center">
   <div class="clip"> <Image/similar class="media" /> </div> </figure>
3. CSS:
   .clip { clip-path: inset(calc((1 - var(--pg-value)) *
     var(--inset-max))); }
   .media { transform: scale(calc(1.05 - var(--pg-value) * 0.05));
     object-fit: cover; }
   axis 'x' → inset(0 calc(...)); axis 'y' → inset(calc(...) 0).
4. A "GalleryFront" variant: two stacked media items; the front one uses
   an inset growing with the progress (revealing the one behind):
   clip-path: inset(calc(var(--pg-value) * 100%) 0 0 0).
5. Videos: muted loop playsInline with a pause outside the viewport.
6. Combine it with captions entering through the observer (Motion 5).
7. prefers-reduced-motion: inset 0 and scale 1 fixed.

Motion 14 — A wall of columns in counter-parallax

The section background is a wall of vertical photograph columns. On scrolling, the columns move in opposite directions: the first goes down while the second goes up, like the gears of a loom. The content floats over it. Zero JS of its own, with the sign alternating per column inside the calc() and nothing more.

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

1. Structure: section[data-progress] (min-h: 130vh) >
   div.wall (absolute inset-0, flex, justify-between, overflow
   hidden) > N div.wall-col (each one with M stacked images) +
   div.content (relative, a z-index above) with a title (ScrambleTitle),
   text (SweepParagraph) and a link.
2. CSS:
   .wall-col { --dist: calc(var(--item-h) / 2);
     transform: translateY(calc((1 - var(--pg-value)) * var(--dist)
       * var(--dir))); will-change: transform; }
   .wall-col:nth-child(odd) { --dir: 1;
     margin-top: calc(var(--item-h) * -1); }
   .wall-col:nth-child(even) { --dir: -1; }
   Columns with 1 item more than needed so an empty edge shows in no
   case during the path.
3. A legibility veil between the wall and the content
   (a dark rgba background or a gradient) and data-theme="dark" on the
   section so the cursor and header invert (Motions 3 and 11).
4. Images with next/image, sizes per column, loading lazy (the section
   sits below the fold) and a blur placeholder.
5. Mobile: reduce it to 2-3 columns and half the distance; under
   prefers-reduced-motion, freeze it (--pg-value handled in the engine).

The footer arrives settling: while the scroll reveals it, the block slides a few centimeters slower than the page, like a tray fitting into place, and it lands when the scroll ends and no sooner. The distance is modest on purpose, since the effect is accommodation rather than a curtain.

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

1. Structure: footer > div.footer-inner[data-progress
   data-start="top bottom" data-end="bottom bottom"] with the groups
   (address, navigation columns, socials, credits/languages).
2. CSS:
   .footer-inner { transform: translateY(calc((1 - var(--pg-value))
     * var(--settle, 8rem))); will-change: transform; }
   (at --pg-value: 1 the footer has landed; the trigger's short path
   makes the settle coincide with the end of the scroll)
3. Links with .u-underline (Motion 9); email and phone as real
   anchors; an external link with the ArrowButton in the diagonal variant.
4. Discreet internal entrances through the observer (Motion 5): groups with
   autoAlpha + y: 16, stagger 0.06 (once).
5. prefers-reduced-motion: --settle: 0.

Step 8 — UI identity: the menu with living dots and the two-layer cursor

Motion 10 — Fullscreen menu with a button of "living" dots

The menu button is a circle with three dots pulsing in a wave, as if the button breathed. Click it: a dark overlay takes the screen and the content enters in layers, with large links, the address and the socials, each group an instant after the previous one. And the architecture easter egg: when the physics layer is active (Motion 17), the dots abandon the keyframe and gain physical bodies that react to the cursor.

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

1. A fixed button (the top corner): <button aria-expanded aria-controls>
   with: div.dots > 3 span.dot; div.circle (a circular background on hover);
   div.labels (a mask with "Menu"/"Close" stacked).
   The dots CSS:
   @keyframes dot-pulse { 0%,100% { transform: translateY(0) }
     50% { transform: translateY(-3px) } }
   .dot { animation: dot-pulse .75s var(--ease-soft-out) infinite; }
   .dot.--2 { animation-delay: .1s } .dot.--3 { animation-delay: .2s }
   When the physics layer is active (Motion 17):
   .--is-physics .dot { animation: none } (the simulation takes over).
2. Label flip: an overflow-hidden mask with 2 divs; open →
   translateY(-100%) reveals "Close".
3. Overlay: a div fixed inset-0 z-50 pointer-events-none; with
   '--is-menu-active' on <html>: the dark background fades in .5s, the content's
   opacity/visibility turn visible and pointer-events auto.
4. Content in 3 groups (main nav, contacts, languages/socials)
   entering with a gsap.fromTo autoAlpha + y: 24, stagger 0.08 between
   items and a 0.1 delay between groups; a quick inverse exit (0.3s).
5. Behavior: Escape closes; lenis.stop()/start(); a focus trap with
   a return to the button; a route change through the menu closes it in parallel with the
   transition (Motion 2); '--is-menu-active' blocks the header hide
   (Motion 3).
6. prefers-reduced-motion: static dots and an overlay with a plain fade.

Motion 11 — Custom cursor in two layers

A marker follows the pointer in two layers: the outer one arrives close to the mouse; the inner one settles an instant later, creating an elastic trail of millimeters. Over interactive areas, it changes state (a growing ring, a dot, an inverted color on dark backgrounds). And the non-negotiable rule: it replaces the system cursor in no case, since it is a satellite.

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

1. Structure in the root layout: div.mouse (fixed top-0 left-0, z-max,
   pointer-events-none) > div.mouse-parent > div.mouse-child.
2. Layered movement (useGSAP):
   const px = gsap.quickTo('.mouse-parent', 'x',
     { duration: 0.25, ease: 'power2.out' });   // the same for 'y'
   const cx = gsap.quickTo('.mouse-child', 'x',
     { duration: 0.55, ease: 'power3.out' });   // the same for 'y'
   window pointermove → px/py and cx/cy with the same coordinates
   (the different durations create the trail).
3. States: .mouse-child::before (a ring, scale 0→1) and ::after (a dot)
   with transitions; a delegated mouseover on the document reading
   closest('[data-cursor]') for the '--link', '--drag',
   '--view' classes; dark sections mark data-theme="dark" and the cursor
   inverts the color (color: currentColor).
4. Visibility: hidden until the first pointermove; it disappears on leaving the
   window (mouseleave on the document) and during '--is-transition'.
5. Render it on (hover: hover) and (pointer: fine) alone; use
   cursor: none on the site in no case (the marker is a complement rather than a substitute).
6. prefers-reduced-motion: a single layer with no trail.

Step 9 — The first slider: a showcase with a central title that swaps (Motion 12)

A row of images and, presiding over the section, a name in large letters. On hovering each card or advancing the slider, the central name swaps: the current one goes out, the new one lights up, along with the description. The section works as a display case whose label follows what your attention touches. In the premium variant, the swap re-fires the Motion 5 scramble in place of a plain fade.

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

1. Data: N items { slug, title, description, image }. Layout:
   a row of cards (overflow + translate by index, or Embla),
   an absolute central title (h2.showcase-ttl) overlaid on the row and
   a stack of descriptions.
2. Title swap (by hover on desktop, by active slide on touch):
   - onEnter(item): gsap.to(ttl, { autoAlpha: 0, duration: 0.2,
     onComplete: () => { ttl.textContent = item.title;
       gsap.to(ttl, { autoAlpha: 1, duration: 0.3 }); } });
     The premium variant: re-fire the ScrambleTitle (Motion 5) on the new
     text in place of the fade.
   - onLeave with no new target: go back to the section's default title.
3. Descriptions: all of them mounted stacked (absolute); the active one at
   opacity 1, the rest at 0 (a .35s transition). aria-live="polite" on the area.
4. Navigation: ArrowButton arrows (Motion 9) with disabled states
   at the ends; optional drag (pointer events → deltaX on the
   translate, snapping to the nearest index on release).
5. Card images: an overflow-hidden frame with a hover zoom
   (scale 1.05, .6s) and a subtle entrance clip through the observer (Motion 5).
6. Sync hover ↔ slide: hover marks the item "in focus" without moving the
   slider; the arrows move the slider and update the title from the central item.

Step 10 — The crown jewel: a slider synced with rings on canvas (Motion 13)

The site's most authorial moment. Beside the project list, a drawing of concentric rings, a direct allusion to the growth rings of the wood that is ANAI's raw material, lives on a 2D canvas. On each advance of the slider, the rings turn half a revolution with easing, as if the time of the material followed the navigation; the page number swaps with a digit flip. Content, counter and drawing pulse together: navigation becomes ritual.

It is Layer 3 at its best: the canvas knows nothing about the slider, since it draws as a function of a continuous number (frames) alone, and the slider tweens that number. Minimal coupling, maximal expressiveness.

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

1. A "Rings" class (2D canvas):
   - new Rings({ elem, color, steps, base, outer, padding }) creates a
     responsive <canvas> (resize observer, dpr) inside elem;
   - draw(frames): clears and draws the concentric rings; use frames
     to modulate the drawing (a phase radius, the rotation of a marker or
     alternating thickness; parameterize the interpretation);
   - update() forces a redraw (called when the section enters view).
   Responsive: fewer rings and a smaller radius on mobile (props per breakpoint).
2. Shared state: const frames = { current: 0, next: 0 }.
   On a slide change (arrows, drag or a click on an item):
   frames.next = frames.current + 0.5;
   gsap.killTweensOf(frames);
   gsap.to(frames, { duration: 0.25, current: frames.next,
     ease: 'power1.out', onUpdate: () => rings.draw(frames.current) });
3. The content slider: text/image panels swapping by index
   (translate or crossfade); the item title with ScrambleTitle
   (Motion 5) on each swap.
4. Pagination with a digit flip:
   <span class="nums"> (1em x 1em, overflow hidden) with two stacked
   .num-child layers; on the swap: the current one gains '--is-hide'
   (translateY(-100%) + fade) and the new one '--is-show' entering from below;
   at the end, swap the roles (2 nodes at all times).
5. Accessibility: the canvas aria-hidden (decorative); the slider with
   role="group", aria-roledescription="carousel", labeled buttons;
   the counter with aria-live="polite".
6. Pause: draw with the section in the viewport alone (an IntersectionObserver);
   clean up the canvas and observers on unmount.
7. prefers-reduced-motion: static rings (a single draw) and slide
   swaps through a fade.

Step 11 — Quick polish: 3D image tilt driven by the mouse (Motion 8)

Certain photographs tilt with a subtle move toward the mouse, like suspended cards reacting to your presence. Nothing chases the cursor: everything bends toward it and nothing more. And here the architecture pays back the Step 2 investment: since the lerp lives in the global tracker, N tilt instances cost the same as one, with no local listener and CSS reading the vars.

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

1. Structure: <figure class="tilt-wrap"> (perspective: 1000px)
   > <div class="tilt" style="--rx: 10deg; --ry: -10deg">
   > <img class="tilt-img">.
2. CSS:
   .tilt { transform: rotateX(calc(var(--y) * var(--ry)))
                      rotateY(calc(var(--x) * var(--rx)));
           will-change: transform; }
   .tilt-img { transition: transform .6s var(--ease-soft-out); }
   .tilt-wrap:hover .tilt-img { transform: scale(1.05); }
   Optional: a ::before overlay with opacity 0 → 1 on hover (a veil).
3. Amplitudes as props (--rx/--ry per instance); invert the signs
   to "flee" or "follow" the mouse according to the direction you want.
4. The smoothness comes from the global tracker's lerp (Motion 4): with no
   local listeners, N instances cost the same as one.
5. On touch and under reduced-motion the vars stay at 0: a static image with
   the hover-scale where real hover exists alone (@media (hover: hover)).

Step 12 — The "wow" layers: WebGL particles and DOM physics

The last two layers are pure sugar, and that is how to treat them: the site has to work the same without them. Under reduced motion or on weak hardware, they mount in no form.

Motion 16 — Ambient WebGL particles

Over the photograph, tiny particles float in the air: dust in sunlight, each one with a size, a brightness and a rhythm of its own, rising and drifting, fading into the distance. Close to invisible when you look straight at it, and that is what makes the scene feel like air rather than wallpaper.

txt
Create a client component "AmbientParticles" in Next.js with Three.js:

1. A "GLCanvas" wrapper: WebGLRenderer({ alpha: true, antialias: true }),
   setPixelRatio(min(devicePixelRatio, 2)), an absolute canvas over the
   media (pointer-events none), resize through a ResizeObserver.
2. Geometry: a BufferGeometry with COUNT (300-800) particles:
   position (random x/y in the volume, z in bands), and the attributes
   aProgress, aSize, aAlpha (Math.random() per particle).
3. Vertex shader: the position displaced by 3D simplex noise
   (freq/multiplier in uniforms) + vertical advance by
   (aProgress + iTime * uSpeed) mod 1; gl_PointSize proportional to
   aSize and to the depth; varying vAlpha.
4. Fragment shader: a soft disc (a radial smoothstep) * vAlpha * fog.
5. Uniforms: iTime (frames), iMouse (read from the global tracker of
   Motion 4 for a subtle drift), iResolution, uSize, uNoiseFrequency,
   uNoiseMultiplier, uProgressSpeed (all of them as props).
6. Material: transparent, blending: THREE.AdditiveBlending,
   depthWrite: false.
7. Loop: gsap.ticker (a named function); render with the canvas
   in the viewport alone (an IntersectionObserver). Cleanup: ticker.remove,
   geometry/material.dispose(), renderer.dispose().
8. Fallback: with no WebGL or under prefers-reduced-motion, mount
   nothing (the scene lives without the particles).

Motion 17 — Physics for DOM elements (Matter.js)

Some interface elements carry real weight: loose in an invisible container, they fall, bounce off the edges and settle; bringing the cursor close pushes them in physical terms, like coins on a tray. On ANAI, the dots of the menu button gain that life when the advanced layer is active, leaving the choreographed pulse behind and starting to react to your mouse.

txt
Create a DOM physics system in Next.js with Matter.js:

1. A "PhysicsWorld({ container, gravity })" class:
   - Engine.create(); walls: 4 static Bodies.rectangle at the edges
     of the container (a generous thickness, invisible);
   - cursor: a static Bodies.circle (radius ~24px) updated on
     pointermove (Body.setPosition), which collides and pushes the bodies;
   - a runner of its own through gsap.ticker (a named function) calling
     Engine.update(engine, 1000/60).
2. A "PhysicsItem({ el, shape: 'rect' | 'circle' })" class:
   - it measures the el (getBoundingClientRect relative to the container) and creates the
     body with restitution .6, friction .1;
   - update(): it reads position/angle from the body and applies it to the el
     transform: translate(xpx, ypx) rotate(deg) (round to 2 places;
     the el sits position: absolute in the measured initial state).
3. An example application (the menu button dots): on activating the layer
   ('--is-physics' on <html>), the dots swap the pulse keyframe
   (animation: none) for physical bodies inside the button circle;
   on deactivating, destroy the world and restore the keyframe.
4. Resize: recreate the walls and reposition the bodies (debounced).
5. Complete cleanup: ticker.remove, Composite.clear, Engine.clear,
   listeners removed.
6. Physics is sugar: on low-performance touch and under
   prefers-reduced-motion, keep the choreographed CSS version.

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

The list grew relative to the previous guide, since canvas and physics bring traps of their own. Go through it before the first line, and come back when something breaks with no visible cause.

  • "use client" is mandatory in every component that touches GSAP, Lenis, Three, Matter or observers.
  • Always useGSAP({ scope }) in place of a raw useEffect: the automatic context.revert() kills orphan tweens and triggers when you navigate between routes.
  • The Lenis cleanup takes 3 steps: gsap.ticker.remove(update) + lenis.off('scroll', ...) + lenis.destroy().
  • Locking the scroll with Lenis: lenis.stop()/start() (the preloader, the menu), and overflow: hidden alone in no case.
  • CSS vars + SSR: initialize --pg-value: 0 and --x/--y: 0 in the CSS (rather than through JS alone) so the server's first paint comes out right.
  • The mouse vars on <html> are global on purpose: N consumers (flare, tilt, cursor, particles) read the same two vars; create a pointermove listener per component in no case.
  • Write the vars with restraint: toFixed(4) and a minimum variation threshold before touching the DOM; writing every frame with no real change is pure cost.
  • transform, opacity and clip-path alone in the CSS-var animations; width, height, top or left in no case (layout thrashing).
  • will-change toggled by visibility (a data-inview attribute through an IntersectionObserver), as the audited site does: a permanent will-change degrades GPU memory.
  • ScrollTrigger.refresh() after images and fonts load and after each route transition.
  • SplitText after the fonts alone (autoSplit: true or document.fonts.ready); aria-label on the parent + aria-hidden on the chars, or screen readers spell the title out.
  • gsap.matchMedia() for responsive variations; GSAP deprecated ScrollTrigger.matchMedia back in 3.11.
  • Global states have a single owner: --is-loading, --is-transition, --is-scrolled, --is-menu-active and data-scroll-direction live in a central store that mirrors classes on <html>; scattered toggles create ghost states.
  • Canvas always with a capped dpr (min(devicePixelRatio, 2)), a pause outside the viewport and a complete dispose (Three: geometry/material/renderer; Matter: Composite/Engine; 2D: observers).
  • Cursor, tilt and flare: desktop only ((hover: hover) and (pointer: fine)); on touch the vars stay at 0 and the components degrade on their own. And cursor: none in no case.
  • Physics and particles are sugar: the site has to work the same without them (a choreographed CSS fallback, the layer left unmounted under reduced motion or weak hardware).
  • The preloader needs real progress: a fake counter stuck at 99 waiting on an asset is the anti-pattern; add up genuine Promises and interpolate with GSAP.
  • prefers-reduced-motion on everything: the preloader becomes a fade, the hero window is born open, the scramble becomes the final color, the sliders swap through a fade.

Where to start today

The entry trio carries the same spirit as the previous guide: Step 1 (the foundation), Step 2 (the double engine, which pays double here because it feeds scroll AND mouse) and Step 5 (underlines and arrows, pure CSS, an immediate result). With Step 3 right after, you hold the typographic signature that changes the face of any page.

And the invitation to comparative study stands: open anaiwood.com and the Plantica site side by side, with both guides open. A wood brand and a floral studio, a hybrid three-layer architecture and a radical CSS-first one, and underneath, the same principles: variables for the continuous, tweens for the events, canvas for the spectacle, and accessibility as a foundation. That repertoire of decisions, rather than the effects themselves, is what you take to the next project.