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

Contents
- The philosophy that changes everything: CSS-first, JS-orchestrated
- The stack and the role of each piece
- The map of the 18 motions
- Step 1 — The foundation: animation infrastructure
- Step 2 — The engine: scroll becomes a CSS custom property (Motion 4)
- Step 3 — The typographic signature: clip/split text (Motion 3)
- Step 4 — Global states: first load and page transitions
- Step 5 — The chrome: 3D menu card, buttons and underlines
- Step 6 — The calling card: hero collage with progressive zoom (Motion 5)
- Step 7 — Sections in series: archive, sticky panels and video iris
- Step 8 — Desktop polish: contextual cursor and floating preview
- Step 9 — The detail page: split-scroll, FLIP zoom and related items
- Step 10 — Infinite scroll in the galleries (Motion 6)
- Step 11 — The closing: decorative sprites and the footer in three acts
- The 15 traps that will bite you (read before coding)
- 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 (
--p1and--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
| Library | Role in the project | Why |
|---|---|---|
| GSAP 3.13+ and @gsap/react | The scroll progress engine (ScrollTrigger scrub → CSS vars) and one-off orchestrations | GSAP 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-end | A very short scrub (0.01–0.1) for almost raw progress; the visual smoothing comes from Lenis + CSS |
| Lenis | Global smooth scroll and infinite mode on gallery pages | The audited site uses Lenis with infinite: true for vertical image loops |
Framer Motion (motion) | Route transitions (template.tsx) and one-off UI states | It 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 mouse | The original uses raw WebGL of a few KB; OGL delivers the same result with less boilerplate than three.js |
| CSS custom properties and transitions | The real animation engine | transform: 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
| # | Motion | Typical use | Trigger |
|---|---|---|---|
| 1 | Splash/preloader + orchestrated entrance | The site's first load | Load |
| 2 | Page transition (exit/entrance) | Every internal navigation | Navigation |
| 3 | Clip/split text system | Titles and paragraphs, across the site | Load/Scroll |
| 4 | Scroll → CSS vars engine (--p1/--p2) | The base of motions 5, 12, 13, 14, 17, 18 | Scroll |
| 5 | Hero collage with progressive zoom | The home hero | Scroll |
| 6 | Smooth scroll + infinite scroll | Global + detail galleries | Scroll |
| 7 | 3D menu card | Header (the Menu button) | Click |
| 8 | Ghost buttons + animated underlines | Global buttons and links | Hover |
| 9 | Custom cursor with labels | Desktop, media areas | Mouse |
| 10 | Floating preview on list hover | Item lists | Hover |
| 11 | Archive: reveals, sticky sort, filters | Listing pages | Scroll/Click |
| 12 | Panels over a sticky background | The featured section on the home | Scroll/Hover |
| 13 | Video expanding through clip-path | The institutional video section | Scroll |
| 14 | Split-scroll detail page | Project/case detail | Scroll |
| 15 | Click-to-zoom on an image (FLIP) | Detail galleries | Click |
| 16 | Horizontal list with drag | Related/next items | Drag |
| 17 | Decorative sprites (pop + parallax) | The institutional page | Load/Scroll |
| 18 | Composite footer | The global footer | Scroll/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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).Step 9 — The detail page: split-scroll, FLIP zoom and related items
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.
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.
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.
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.
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).Step 11 — The closing: decorative sprites and the footer in three acts
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.
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.Motion 18 — Composite footer: parallax reveal, marquee and a blur canvas
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.
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 rawuseEffect: the automaticcontext.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 thanoverflow: hiddenalone; anddata-lenis-preventon areas with a scroll of their own. - CSS vars + SSR: initialize
--p1: 0; --p2: 1in the CSS, rather than through JS alone, or a flash appears on the first paint. transform,opacityandclip-pathalone 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 globalwill-changedegrades 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: trueordocument.fonts.ready); and alwaysaria-labelon the parent +aria-hiddenon the spans. gsap.matchMedia()for responsive trigger variations; GSAP deprecatedScrollTrigger.matchMediaback 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. Andcursor: nonein 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-motionon 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.
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


