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

Contents
- The philosophy: three layers, each one in its place
- The stack and the role of each piece
- The map of the 18 motions
- Step 1 — The foundation: animation infrastructure
- Step 2 — The double engine: --pg-value on scroll, --x/--y on the mouse (Motion 4)
- Step 3 — The typography that lights up: scramble and sweep (Motion 5)
- Step 4 — Global states: preloader, transitions and the smart header
- Step 5 — A quick win: underlines with a swapped origin and arrows that breathe (Motion 9)
- Step 6 — The calling card: the window that opens and the lens flare
- Step 7 — Series production: clips, a counter-parallax wall and the footer
- Step 8 — UI identity: the menu with living dots and the two-layer cursor
- Step 9 — The first slider: a showcase with a central title that swaps (Motion 12)
- Step 10 — The crown jewel: a slider synced with rings on canvas (Motion 13)
- Step 11 — Quick polish: 3D image tilt driven by the mouse (Motion 8)
- Step 12 — The "wow" layers: WebGL particles and DOM physics
- The 18 traps that will bite you (read before coding)
- 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 withcalc(): 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
| Library | Role in the project | Why |
|---|---|---|
| GSAP 3.13+ and @gsap/react | Per-section entrance tweens, text scramble (SplitText), preloader timelines | GSAP 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 |
| Lenis | Smooth 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 stagger | The 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.js | Physics for micro DOM elements (synced bodies + a cursor body) | Playful micro-interactions at a low cost |
| CSS custom properties and transitions | Clips, tilts, parallax and underlines consuming --pg-value/--x/--y | Scroll 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
| # | Motion | Typical use | Trigger |
|---|---|---|---|
| 1 | Preloader with progress + handoff | First load | Load |
| 2 | Route transition with a mask | Every internal navigation | Navigation |
| 3 | Smooth scroll + a smart header | Global | Scroll |
| 4 | Progress engine → CSS vars + global mouse | The base of motions 6, 7, 8, 14, 15, 18 | Scroll/Mouse |
| 5 | Text: color scramble + sweep | Titles and paragraphs | Inview |
| 6 | Hero: a clip window that opens | The home hero | Load/Scroll |
| 7 | Layered lens flare driven by the mouse | Hero/atmospheres | Mouse |
| 8 | 3D image tilt driven by the mouse | Editorial images | Mouse/Hover |
| 9 | Underlines and arrows with micro-animation | Links and navigations | Hover |
| 10 | Fullscreen menu with a "living" button | Global header | Click |
| 11 | Custom cursor in 2 layers | Desktop | Mouse |
| 12 | Showcase slider with a central title | The showcase on the home | Hover/Click |
| 13 | Slider synced with rings on canvas | The project showcase | Click/Drag |
| 14 | Columns in counter-parallax | An immersive thematic section | Scroll |
| 15 | Media clip reveals by scroll | Subpages and galleries | Scroll |
| 16 | Ambient WebGL particles | Atmospheres/hero | Automatic |
| 17 | Physics for DOM elements | Playful micro-interactions | Mouse |
| 18 | Footer with a parallax settle | Global footer | Scroll |
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.Step 7 — Series production: clips, a counter-parallax wall and the footer
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.
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.
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).Motion 18 — Footer with a parallax settle
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.
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.
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.
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.
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.
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.
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.
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.
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 rawuseEffect: the automaticcontext.revert()kills orphan tweens and triggers when you navigate between routes. - The Lenis cleanup takes 3 steps:
gsap.ticker.remove(update)+lenis.off('scroll', ...)+lenis.destroy(). - Locking the scroll with Lenis:
lenis.stop()/start()(the preloader, the menu), andoverflow: hiddenalone in no case. - CSS vars + SSR: initialize
--pg-value: 0and--x/--y: 0in 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,opacityandclip-pathalone in the CSS-var animations; width, height, top or left in no case (layout thrashing).will-changetoggled by visibility (adata-inviewattribute through an IntersectionObserver), as the audited site does: a permanentwill-changedegrades GPU memory.ScrollTrigger.refresh()after images and fonts load and after each route transition.- SplitText after the fonts alone (
autoSplit: trueordocument.fonts.ready);aria-labelon the parent +aria-hiddenon the chars, or screen readers spell the title out. gsap.matchMedia()for responsive variations; GSAP deprecatedScrollTrigger.matchMediaback in 3.11.- Global states have a single owner:
--is-loading,--is-transition,--is-scrolled,--is-menu-activeanddata-scroll-directionlive 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. Andcursor: nonein 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-motionon 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.
Read next
Motion •
Motion Design for the Web: The Complete Guide
Scroll, text, images and video: the complete catalog of motion techniques for the web, with implementation in Next.js and the cases where each one pays off.
- motion
- scroll
The definitive guide — a Next.js site built around motion and scroll
The scroll foundation that, when missing, keeps the animations from working at all: Lenis, GSAP and Next.js wired in the right order and the mistakes to avoid.
- next.js
- lenis
Infra •
Documentation: deploying a Next.js application with GitHub + Hostinger
Every push becomes a live site with no hosting panel involved: connecting GitHub to Hostinger, the build settings that break and the checks after each deploy.
- deploy
- github


