Premium motions in Next.js: the 15 patterns of the Studio Modular site, step by step
The componentized architecture of the Studio Modular site: gated modules, native scroll and cutting-edge CSS, in 15 motion patterns with ready-made prompts.
- motion
- gsap

Contents
- The philosophy: modules that know when to sit out
- The stack and the role of each piece
- The map of the 15 motions
- Step 1 — The foundation: infrastructure and the three gates
- Step 2 — Quick wins in pure CSS: the button and the scroll badge
- Step 3 — The calling card: a split wordmark that assembles and comes apart
- Step 4 — The technical jewel: a nav pill with CSS Anchor Positioning (Motion 4)
- Step 5 — The accessibility pattern: a menu that blooms in a circle (Motion 5)
- Step 6 — The most sophisticated scrub: "ink over the draft" text (Motion 7)
- Step 7 — Declarative scrubs in series: parallax and circular morph
- Step 8 — Sticky with honesty: stacked cards with fit measurement (Motion 8)
- Step 9 — The marquee that obeys your scroll (Motion 6)
- Step 10 — The portfolio set: cursor chaser and responsive grids
- Step 11 — The closing: looping testimonials and the "now playing"
- The 15 traps that will bite you (read before coding)
- Where to start today
The third reverse-engineering study in the series, and the third animation philosophy that differs from the others in every respect. In the Plantica guide, the JS animated close to nothing: it wrote variables and the CSS moved things. In the ANAI guide, three layers divided the work between vars, tweens and canvas. The site this time answers with the most disciplined approach of the three, and odds are the one that transfers best to your day-to-day work with React.
The audited site is Studio Modular (studiomodular.be), a Belgian graphic design studio: giant dark green typography on cream, nav in a glass pill, the "How we roll" badge spinning in the hero and a direct invitation, "Start jouw project". Behind the personality sits a 100% componentized theme over a multi-page CMS: GSAP, ScrollTrigger and SplitText loaded on demand, Swiper, and cutting-edge CSS doing work you would swear was JavaScript: Anchor Positioning, :has(), clip-path: circle(). An audit of that site produced the 15 patterns below, translated into the Next.js ecosystem; names, texts and images are placeholders. You take away the pattern, the technique and a prompt ready to paste into your AI agent.
As always in the series, the order of the steps is the order of construction, from the groundwork to the final charm.
The philosophy: modules that know when to sit out
The engineering signature of Studio Modular is a discipline rather than an effect. Each motion is an isolated module that follows four rules:
- It initializes through a data attribute in the markup (
data-parallax,data-marquee-trigger…): the effect gets declared where it lives, rather than configured in a central file. - It imports GSAP on demand, when it needs it: a page with no motion pays no cost.
- It refuses to initialize when
prefers-reduced-motion: reduceis active or the breakpoint fails to carry the effect. Note the wording: rather than "reducing the animation", it skips mounting the logic at all. Zero cost for whoever will see none of it. - It watches itself: a ResizeObserver to recalculate, an IntersectionObserver to pause work outside the viewport.
And two structural decisions that break with the previous guides: the scroll is 100% native (no smooth scroll library, and everything works without a hitch), and a good share of the sophistication is pure modern CSS: the sliding menu indicator is Anchor Positioning with no line of JS, the overlay blooms with clip-path: circle(), composed hovers use :has().
The stack and the role of each piece
| Library | Role in the project | Why |
|---|---|---|
| GSAP 3.13+ and @gsap/react | Hero entrance timelines, scrubs (text, wordmark, morph, parallax), the marquee ticker, the chaser's quickTo | GSAP is 100% free, every plugin included, since the Webflow acquisition |
| ScrollTrigger (a GSAP plugin) | Every effect tied to the scroll (scrub) | The audited site uses native scroll + ScrollTrigger directly, with no smooth scroll library |
| SplitText (a GSAP plugin) | Splitting into lines for the "ink over the draft" reveal | Automatic re-split on resize; a font gate is mandatory |
| Embla Carousel (or Swiper) | The looping testimonial slider | The audited site uses Swiper (slidesPerView auto, loop); Embla is the idiomatic equivalent in React |
| Modern CSS | Anchor Positioning (the nav pill), clip-path: circle() (the menu), :has() (composed hovers), backdrop-filter | A good share of the motions in this catalog is pure CSS with progressive enhancement |
No Lenis in this catalog: the audited site runs on native scroll and every effect works without a hitch that way. If your project asks for the "buttery" feel, add Lenis through the pattern of the two previous guides (sync + a 3-step cleanup); no motion here conflicts with it.
The map of the 15 motions
| # | Motion | Typical use | Trigger |
|---|---|---|---|
| 1 | Hero opening: a split wordmark + media that sprouts | The home hero | Load |
| 2 | Wordmark separation on scroll | The home hero | Scroll |
| 3 | A "scroll" badge with rotating circular text | Hero | Automatic loop |
| 4 | Sliding nav pill (Anchor Positioning) | Desktop header | Hover |
| 5 | An overlay menu that blooms in a circle | Mobile/global menu | Click |
| 6 | A marquee responsive to the scroll direction | Text/logo bands | Loop + Scroll |
| 7 | "Ink over the draft" text scrub | A manifesto/long intro | Scroll |
| 8 | Sticky stacked cards with fit measurement | A services/plans list | Scroll |
| 9 | Media morphing into a circle | A section divider | Scroll |
| 10 | Declarative parallax through data attributes | Editorial images | Scroll |
| 11 | A cursor chaser with a spinning badge | Project cards | Hover |
| 12 | A button with a circle that switches sides | Global CTAs | Hover |
| 13 | A looping testimonial slider | Social proof | Drag/Arrows |
| 14 | Responsive grids: alternating masonry + fused tiles | Portfolio and link lists | Resize |
| 15 | A "now playing" widget | Footer (a personal touch) | Loop/Hover |
The audited site is multi-page with no route transitions; the cohesion comes from the consistency of the motions. In Next.js, a light cross-fade through template.tsx is an optional complement that belongs outside the catalog.
Step 1 — The foundation: infrastructure and the three gates
Everything depends on this step, and it differs from the previous guides: in place of a smooth scroll provider, the heart here is the gate hooks that every module uses as umbrellas. It is the discipline of the audited site put into code: reduced motion, breakpoint and fonts checked before any logic mounts.
In a Next.js 14+ project with App Router, TypeScript and Tailwind, set up the
animation infrastructure:
1. Install: gsap @gsap/react embla-carousel-react
2. Register the plugins once in lib/gsap.ts:
gsap.registerPlugin(ScrollTrigger, SplitText, useGSAP); export gsap.
Imported by client components alone.
3. Create the utility hooks that EVERY motion will use as
umbrellas (it is the pattern of the audited site):
- usePrefersReducedMotion(): it reads '(prefers-reduced-motion: reduce)';
motion components initialize NO effects when it is true (rather than
"reducing" alone: many mount no logic whatsoever).
- useMediaQuery(query): reactive to matchMedia, for per-component
breakpoint gates (parallax on >= 980px alone,
stacking on >= 1440px alone, for example).
- useFontsReady(family: string): it resolves when
document.fonts.load(`16px '${family}'`) returns fonts; a mandatory
gate before any SplitText.
4. Design tokens in globals.css (the audited site derives animation
measurements from the tokens):
:root {
--tk-radius: 1.25rem; /* the default radius of media/cards */
--tk-btn-height: 3.5rem;
--tk-header-height: 4.5rem;
--tk-speed-ui: 0.25s;
--tk-stacking-gap: 1.25rem;
}
5. The motion component pattern (document it in an internal README):
"use client" + useGSAP({ scope }) + gates (reduced motion,
breakpoint, fonts) + a ResizeObserver for recalculation +
an IntersectionObserver to pause work outside the viewport.
No global listeners outside a cleanup.
6. A centralized ScrollTrigger.refresh(): after relevant images load
and after layout changes (the util can expose
refreshOnImagesLoaded(scope)).
Deliver the complete files.Step 2 — Quick wins in pure CSS: the button and the scroll badge
Two components with no line of GSAP that establish the tokens and give the project a face already.
Motion 12 — A button with a circle that switches sides
The pill CTA carries a colored circle with an arrow tucked against one end. On hover, a choreographed swap: the original circle shrinks until it vanishes while an identical one sprouts at the opposite end, the arrow rotates to adjust its aim and the label changes color. The eye reads it as: "the button pointed where you are going". And the architecture bonus: the trigger-by-child pattern, where a button living inside a card gets its hover fired by :has() on the parent when you hover anywhere in the card.
Create a "CircleSwapButton" component in Next.js (pure CSS):
1. Markup: <a class="btn"> <span class="circle" aria-hidden>
<i>→</i></span> <span class="label">Label</span>
<span class="circle" aria-hidden><i>→</i></span> </a>
2. CSS:
.btn { position: relative; display: inline-flex; align-items:
center; height: var(--tk-btn-height); border-radius: 999px;
padding-inline: 0 var(--tk-btn-height);
transition: padding-inline .25s; }
.btn:active { scale: .98; }
.circle { position: absolute; width: var(--tk-btn-height);
aspect-ratio: 1; border-radius: 50%;
background: var(--tk-btn-circle-bg);
display: grid; place-items: center;
transition: scale .2s ease-in; }
.circle:first-child { left: 0; transform-origin: left center;
scale: 1; } .circle:first-child i { rotate: -45deg; }
.circle:last-child { right: 0; transform-origin: right center;
scale: 0; } .circle:last-child i { rotate: 45deg; }
.circle i { transition: rotate .2s ease-in; }
.btn:is(:hover, :focus-visible) {
padding-inline: var(--tk-btn-height) 0; }
.btn:is(:hover, :focus-visible) .circle:first-child { scale: 0; }
.btn:is(:hover, :focus-visible) .circle:last-child { scale: 1; }
.btn:is(:hover, :focus-visible) .circle i { rotate: 0deg; }
.btn:is(:hover, :focus-visible) .label {
color: var(--tk-btn-color-hover);
background: var(--tk-btn-bg-hover); }
(the padding-inline switching sides opens room for the circle at the
opposite end without changing the total width)
3. The trigger-by-child pattern (a card hover drives the button):
.trigger-parent:has(.trigger-child:is(:hover, :focus)) .btn
{ …the same hover rules… }
4. Color variants through CSS vars (--tk-btn-*) in modifiers.
5. focus-visible with a ring of its own beyond the state; touch keeps the
resting state.Motion 3 — A "scroll" badge with rotating circular text
A small round badge in the corner of the hero: a short phrase written in a circle, rotating without pause like a stamp in slow rotation, with a little arrow bouncing at the center. Discreet, hypnotic, and it says "scroll" without saying it. SVG with textPath + keyframes, zero JS.
Create a "ScrollBadge" component in Next.js (a Server Component, CSS alone):
1. An accessible SVG (aria-hidden on the decorative part + sr-only text "Scroll
down"): <svg viewBox="0 0 100 100"> <defs> <path id="ring"
d="M50,50 m-38,0 a38,38 0 1,1 76,0 a38,38 0 1,1 -76,0"/> </defs>
<text><textPath href="#ring">SHORT PHRASE • SHORT PHRASE •
</textPath></text> </svg> + a central arrow icon.
2. CSS:
.badge-text svg { animation: badge-rotate 16s linear infinite; }
@keyframes badge-rotate { to { transform: rotate(360deg) } }
.badge-icon { animation: badge-bounce 1.6s ease-in-out infinite; }
@keyframes badge-bounce { 0%, 100% { transform: translate(-10%) }
50% { transform: translate(10%) } }
(the bounce runs on the axis the arrow points along; for a vertical arrow,
swap in translateY)
3. transform-origin: center on the SVG; a fluid size with clamp().
4. The badge is a link to the first section (native smooth scroll through
scroll-behavior or scrollIntoView).
5. prefers-reduced-motion: animation: none on both.Step 3 — The calling card: a split wordmark that assembles and comes apart
The hero validates timeline and scrub in the same component: the two GSAP modes working in sequence.
Motion 1 — The opening: the mechanism assembles itself
The first scene assembles like a mechanism. The brand name arrives split in half across the middle: the top half slides in from the left and the bottom half from the right, fitting together like two plates of a press. An instant later, the photographs sprout from their own center, starting as a point and growing to the full frame, keeping the rounded corners during the growth. Nothing "appears": everything gets built.
The technical detail that separates good from impeccable: keeping the round r in both states of the clip-path. Omit the radius on one side and the corner "jumps" on the first frame.
Create a client component "HeroIntro" in Next.js:
1. Structure: section[data-hero] >
- div.logo with TWO rows of SVG: [data-logo-top] (the upper half
of the letters) and [data-logo-btm] (the lower half); the
wordmark is an SVG cut in half horizontally, each half with
overflow-hidden on the wrapper;
- figure[data-hero-media-1] and figure[data-hero-media-2]
(next/image fill, a frame with border-radius: var(--tk-radius));
- p[data-hero-text].
2. A timeline in useGSAP (gate: usePrefersReducedMotion() === false):
const r = getComputedStyle(document.documentElement)
.getPropertyValue('--tk-radius').trim();
const tl = gsap.timeline();
tl.fromTo('[data-logo-top] svg', { xPercent: -100 },
{ xPercent: 0, duration: 1.5, ease: 'power4.out' }, 0)
.fromTo('[data-logo-btm] svg', { xPercent: 100 },
{ xPercent: 0, duration: 1.5, ease: 'power4.out' }, 0)
.fromTo('[data-hero-media-1]',
{ clipPath: `inset(50% 50% 50% 50% round ${r})` },
{ clipPath: `inset(0% 0% 0% 0% round ${r})`,
duration: 1.2, ease: 'power4.out' }, 0.2)
.fromTo('[data-hero-media-2]', { …same… }, { …same… }, 0.4)
.fromTo('[data-hero-text]', { opacity: 0 },
{ opacity: 1, duration: 1.2, ease: 'power4.out' }, 0.3);
(keeping the "round r" in BOTH clip states is what preserves the
rounded corners during the growth)
3. Images with priority + a blur placeholder; fire the timeline after
the decode() of the hero media so the clip reveals an empty image
in no case.
4. prefers-reduced-motion: the component mounts everything in the final state,
with no timeline (the audited site initializes the module in no form).Motion 2 — The separation: the press reopens on scroll
The fit of the opening comes undone in controlled slow motion: as you scroll, the top half of the name slides right and the bottom half slides left, the reverse of their arrival, until they leave the frame. The speed is the speed of your scroll: stop, and it freezes.
Extend HeroIntro with the scroll separation:
1. Still inside useGSAP, after the entrance timeline:
const st = {
trigger: heroRef.current,
scrub: 1,
invalidateOnRefresh: true,
start: () => `top ${heroRef.current.getBoundingClientRect().top
+ window.scrollY - 10}px`,
end: 'bottom top'
};
gsap.fromTo('[data-logo-top] svg', { xPercent: 0 },
{ scrollTrigger: st, xPercent: 100, ease: 'none' });
gsap.fromTo('[data-logo-btm] svg', { xPercent: 0 },
{ scrollTrigger: st, xPercent: -100, ease: 'none' });
(the same configuration OBJECT can go to both tweens:
each tween creates its own ScrollTrigger from it, the way the
audited site does; what you can share in NO case is an already created
instance. A cleaner alternative: a single timeline with one trigger)
2. The entrance (Motion 1) ends at xPercent: 0, so the scrub starts from that
same value; if the user scrolls during the entrance, the default GSAP
overwrite handles it (leave overwrite: 'auto').
3. ease 'none' is mandatory under scrub: the smoothing comes from scrub: 1.
4. prefers-reduced-motion: no scrub (the wordmark scrolls normally
with the page).Step 4 — The technical jewel: a nav pill with CSS Anchor Positioning (Motion 4)
The top menu is a frosted glass capsule. Inside it, a colored tablet marks the active page; on hovering another link, the tablet slides in physical form along the capsule to reach it, like a magnetic cursor, and returns to the active one when the mouse leaves.
Everyone has implemented that by measuring offsets with JavaScript. Studio Modular did otherwise: it is pure CSS Anchor Positioning, where the active link declares anchor-name, the hovered link declares another, and the tablet is a ::before whose anchor swaps through :has(), with a transition on left. Zero JavaScript, and the slide follows keyboard focus for free. Textbook progressive enhancement: wrapped in @supports, with a dignified fallback.
Create a "NavPill" component in Next.js:
1. Structure: a fixed central nav > ul.menu (the pill: background
rgba(255,255,255,.8), backdrop-filter: blur(7px), border-radius
pill, overflow hidden, height var(--tk-btn-height)) > links with
generous padding-inline.
2. CSS (progressive enhancement with @supports):
@supports (anchor-name: --a) {
.menu { --pad: 1.5rem; --off: .625rem; }
.menu a[aria-current="page"] { anchor-name: --nav-active; }
.menu a:is(:hover, :focus-visible) { anchor-name: --nav-hover; }
.menu::before {
content: ''; position: absolute; z-index: -1;
position-anchor: --nav-active;
left: calc(anchor(start) + var(--off));
width: calc(anchor-size(width) - var(--off) * 2);
height: calc(100% - var(--off) * 2);
border-radius: inherit; background: var(--tk-color-accent);
transition: left .25s ease-out, width .25s ease-out;
pointer-events: none;
}
.menu:has(a:is(:hover, :focus-visible))::before {
position-anchor: --nav-hover;
}
}
3. Fallback without support (older Safari/Firefox): inside a
@supports not, the active link gains the tablet as a background
of its own (with no slide). The menu stays 100% functional.
4. aria-current="page" on the active link (through usePathname) is what
positions the tablet at rest: keep it correct per route.
5. The slide follows keyboard focus as well (focus-visible in the
selector), for free.Step 5 — The accessibility pattern: a menu that blooms in a circle (Motion 5)
Tapping "Menu" makes the screen bloom: a circle of color is born at the center and expands until it covers everything; a fraction of a second behind, a second circle repeats the wave, a drop-in-water effect, while the content grows from inside. Closing reverses the ripple.
The motion is pure CSS (clip-path: circle() in two waves through transition-delay). But the reason this step comes early is another: it establishes the accessibility pattern for overlays that the audited site executes in exemplary fashion: inert on the closed overlay, aria-expanded, Escape, focus returned to the trigger. It is the minimum cost of a serious overlay, and you will reuse the ergonomics everywhere.
Create a client component "BloomMenu" in Next.js:
1. Structure: button[data-menu-trigger] (in the header) +
div.overlay (fixed inset-0 z-50, background in the primary color,
clip-path: circle(0% at 50% 50%), transition: clip-path .3s linear)
> div.overlay-outer (the same clip and transition, with
transition-delay: .25s) > div.overlay-inner (height: 100dvh,
scale: 0, transition: scale .3s .25s) holding the navigation.
2. The open state (an 'overlay-open' class on <html>):
.overlay-open .overlay,
.overlay-open .overlay-outer { clip-path: circle(80% at 50% 50%); }
.overlay-open .overlay-inner { scale: 1; }
.overlay-open { overflow: clip; } /* locks the native scroll */
3. An accessible toggle (replicate it faithfully):
- aria-expanded on the button; the label alternates "Menu"/"Close" (the texts
coming from props or a dictionary);
- the overlay receives the inert attribute while CLOSED (focus enters
it invisibly in no case) and loses the inert on opening;
- Escape closes; on closing, the focus returns to the button; a "blur guard"
on the last focusable element returns the focus to the button (a loop).
4. Large menu links; on navigating, close before the push.
5. prefers-reduced-motion: swap the circles for a plain fade
(an opacity transition), keeping the whole ergonomics.Step 6 — The most sophisticated scrub: "ink over the draft" text (Motion 7)
A large text sits on the page in a faded tone, like a pencil draft. As you scroll, an "inked" version gets written over it, line by line: the first line fills from left to right, then the second, always at the exact rhythm of the scroll. Scroll back and the ink "unwrites". It is reading with your eyes and your finger at the same time.
The most elegant technique in the catalog: SplitText divides it into lines; for each line the JS injects a clone of the content itself in full color with clip-path: inset(0 100% 0 0); a timeline with scrub animates the clip of each clone with a stagger equal to the duration, the arithmetic secret that makes the lines fill in perfect sequence, with no overlap and no gap. This step also validates the font gates and the ResizeObserver re-split, which you will need in any serious work with SplitText.
Create a client component "InkText" in Next.js:
1. Structure: div[data-ink-text] with the paragraph(s). CSS:
the base text in a faded color (--tk-color-dim);
.line { position: relative; }
.line-ink { position: absolute; top: 0; left: 0; width: 100%;
pointer-events: none; z-index: 1; color: var(--tk-color-ink);
clip-path: inset(0 100% 0 0); }
2. Pipeline (useGSAP), after useFontsReady(family) ALONE:
let split, tl;
const build = () => {
tl?.revert(); split?.revert();
split = SplitText.create(els, { type: 'lines',
linesClass: 'line',
onSplit: (self) => {
self.lines.forEach((line) => {
line.innerHTML += `<span class="line-ink" aria-hidden="true">
${line.innerHTML}</span>`;
});
tl = gsap.timeline({ scrollTrigger: {
trigger: rootRef.current,
start: 'top 80%', end: 'bottom 40%', scrub: 0.8 } })
.to(rootRef.current.querySelectorAll('.line-ink'), {
clipPath: 'inset(0 0% 0 0)', duration: 1.5,
ease: 'none', stagger: 1.5 });
} }); // stagger === duration ⇒ lines in exact sequence,
}; // with no overlap and no gap
build();
new ResizeObserver(build).observe(rootRef.current);
(cleanup: revert the split and the tl + disconnect the observer)
3. Accessibility: the clones carry aria-hidden (the base text gets read
already); no duplicated aria.
4. prefers-reduced-motion: no split; render the text directly in
the full color.Step 7 — Declarative scrubs in series: parallax and circular morph
With the scrub pattern validated, two effects come out close to free.
Motion 10 — Declarative parallax through data attributes
Certain images drift with a smooth move while the page scrolls, giving the layout layered depth. The leanest possible pattern: data-parallax on the element, an optional data-offset, and a hook sweeps everything with a scrub tween. The breakpoint gate and the reduced-motion one live in the matchMedia query itself: pure elegance.
Create a client hook "useParallax(scopeRef)" in Next.js:
1. Inside useGSAP({ scope }), with mm = gsap.matchMedia():
mm.add('(min-width: 980px)', () => {
gsap.utils.toArray('[data-parallax]', scope).forEach((el) => {
gsap.to(el, {
y: el.dataset.offset || '10%',
ease: 'none',
scrollTrigger: { trigger: el, start: 'top bottom',
end: 'bottom top', scrub: 1 }
});
});
});
2. Declarative use in the markup:
<figure data-parallax data-offset="14%">…</figure>
Negative offsets ("-8%") drift the other way.
3. The outer frame needs slack (controlled visible overflow or
bleed in the layout) so the drift exposes no empty edges.
4. matchMedia unmounts the triggers outside the breakpoint already; reduced
motion falls under the same gate (add the condition to the query:
'(min-width: 980px) and (prefers-reduced-motion: no-preference)').Motion 9 — Media morphing into a circle
A rectangular photograph lives between two sections. As the scroll crosses the stretch, the frame transforms without pause into a perfect circle: visual punctuation before the page moves on. A single tween: animating the radius of the inset alone (inset(0% round 50%)), keeping the four edges at 0%, morphs the shape without changing the size.
Create a client component "CircleMorph" in Next.js:
1. Structure: div[data-circle-morph] (a wrapper with aspect-ratio 1/1 or
controlled) > div[data-circle-item] (the media, overflow hidden,
initial clip-path: inset(0% round var(--tk-radius))).
2. useGSAP (gate: reduced motion turns it off):
gsap.to('[data-circle-item]', {
scrollTrigger: { trigger: rootRef.current, scrub: 1,
invalidateOnRefresh: true,
start: 'top bottom', end: 'bottom bottom' },
clipPath: 'inset(0% round 50%)', ease: 'none'
});
(animating the inset radius alone, keeping 0% on the four edges,
gives the rectangle → circle morph without changing the size)
3. For a perfect circle, the item needs a 1:1 aspect in the final
stretch; on wide wrappers, accept the ellipse (elegant as well) or
animate the lateral inset too until it squares up.
4. The image with object-fit: cover and a centered composition (the final
circular crop has to work as framing).Step 8 — Sticky with honesty: stacked cards with fit measurement (Motion 8)
The service cards fail to pass by: they accumulate. The first sticks to the top, the second one step below it, the third one step below the second: a fan of visible edges, like playing cards. And here is the lesson of this motion: the JS animates nothing. The stacking is position: sticky + offsets in CSS; the JavaScript measures whether the effect fits (the tallest card × the available height) and turns the class on or off. It failed to fit? It becomes a normal list, with no jumps. That honest measurement separates premium sticky stacks from broken ones.
Create a client component "StackingCards" in Next.js:
1. Structure: section[data-stacking] > ol > li.stack-item *N, each one
with an article.card (the content). The component sets
--stack-total: N on the container.
2. CSS (active with .can-sticky on the container alone):
.can-sticky { padding-bottom: calc(var(--stack-total) *
var(--tk-stacking-gap)); margin-top: calc(var(--tk-stacking-gap)
* -1); }
.can-sticky .stack-item { position: sticky;
top: calc(var(--tk-header-height)); }
.can-sticky .stack-item:nth-child(1) { translate: 0
calc(var(--tk-stacking-gap) * 1); }
.can-sticky .stack-item:nth-child(2) { translate: 0
calc(var(--tk-stacking-gap) * 2); }
…generalize it with a util or an inline style --i and
translate: 0 calc(var(--tk-stacking-gap) * var(--i)).
.card { height: var(--stack-card-height, auto); }
3. Measurement (the heart of the component):
- availableHeight = viewport - (half the top padding +
the header height + a margin);
- tallestCard = max(offsetHeight of the cards) with the height var
REMOVED before measuring;
- if tallestCard < availableHeight: set
--stack-card-height: tallestCard px on all of them (equal heights give
the perfect fan) and turn .can-sticky on; otherwise turn it all off.
4. Gates: useMediaQuery('(min-width: 1440px)') turns on and off a
ResizeObserver that runs the measurement; usePrefersReducedMotion()
true ⇒ the component initializes in no way (a static list).
5. No ScrollTrigger: native sticky does the stacking on its own.Step 9 — The marquee that obeys your scroll (Motion 6)
A band of words slides across the screen without pause, and the detail that brings it to life: it obeys your scroll. Scrolling down, it runs left; reverse the scroll and the band reverses with you, like a conveyor geared to your gesture. Off screen, it stops burning energy.
No keyframes: a GSAP ticker moves x at a speed compensated by deltaRatio(), the detail that keeps the marquee from running 2x faster on 120Hz monitors. Test that one thing above all.
Create a client component "DirectionalMarquee" in Next.js:
1. Props: speed (default 0.8), reverse (default false), children
(the band items). Structure: div.marquee (overflow hidden) >
div.track > div.group (the items); the component clones the group
to fill it: if group.offsetWidth < viewport, clone the inner items
ceil(viewport/width) - 1 times; then append TWO copies
of the group to the track. Store: groupW, trackW and the wrap limits
(min = -(trackW - viewport), reset = -(groupW - viewport),
max = 0, resetUp = -groupW * 2).
2. Movement (useGSAP):
const setX = gsap.quickSetter(track, 'x', 'px');
let x = -track.offsetWidth * 0.5; // the middle of the path: slack
// for both directions
const tick = () => {
const step = (1.5 - Math.pow(0.8, gsap.ticker.deltaRatio()))
* speed; // FPS compensation
const movingLeft = (scrollDir === 'down') !== reverse;
if (movingLeft) { if (x <= min) x = reset; x -= step; }
else { if (x >= 0) x = resetUp; x += step; }
setX(x);
};
3. Direction: a passive scroll listener comparing window.scrollY with
the previous value → scrollDir 'down' | 'up'.
4. An IntersectionObserver on the component: entered → gsap.ticker.add(tick);
left → gsap.ticker.remove(tick). The cleanup removes it all.
5. Resize: a 250ms debounce → re-clone and re-measure (remove the old clones
first).
6. Accessibility: aria-hidden on the band + an sr-only version of the content;
prefers-reduced-motion: render the band static (with no clones and no
ticker: the audited site does exactly that).Step 10 — The portfolio set: cursor chaser and responsive grids
Motion 11 — A cursor chaser with a spinning badge
On entering a project card with the mouse, a round badge materializes right where the cursor is and starts chasing it with an elastic lag inside the card. On the badge, an invitation phrase spins in a circle. Leave the card and the badge retracts wherever it stands.
The birth trick: on the first movement, pass the coordinate twice to quickTo, since that sets from and to together and the badge is born under the cursor in place of traveling from the corner. It is one character of code that changes the whole sensation.
Create a client component "CursorBadgeCard" in Next.js:
1. Structure: article.card (position relative, [data-chaser-parent])
> media/content + div.badge ([data-chaser], position absolute
top-0 left-0, pointer-events none) > div.badge-text (a circular SVG
textPath, animation: badge-spin 7s linear infinite, scale: 0,
transition: scale .2s ease-out) + div.badge-core (a central label or arrow,
scale 0, transition .2s).
.card.active .badge-text, .card.active .badge-core { scale: 1; }
2. The chase (useGSAP; gates: matchMedia('(hover: hover)') and
reduced motion false):
const toX = gsap.quickTo(badge, 'x', { duration: .4,
ease: 'power3' });
const toY = gsap.quickTo(badge, 'y', { duration: .4,
ease: 'power3' });
let first = true;
const move = (e) => {
const r = card.getBoundingClientRect();
const x = e.clientX - r.left, y = e.clientY - r.top;
if (first) { toX(x, x); toY(y, y); first = false; }
else { toX(x); toY(y); }
}; // (passing the value 2x sets from and to: the badge IS BORN under
// the cursor in place of traveling from the corner)
pointerover → card.classList.add('active') +
card.addEventListener('pointermove', move);
pointerout → remove the class and the listener; first = true.
3. The whole card is a link; the badge is decorative (aria-hidden).
Keyboard focus shows the badge centered (the active class through
focus-within, with no chase).
4. On touch: none of it mounts; the card works as an ordinary link.Motion 14 — Responsive grids: alternating masonry and fused tiles
Two composition utilities that look like layout magic: the portfolio in two offset columns with a guaranteed alternating order (the 1st on the left, the 2nd on the right…), and rows of blocks whose 1px borders fuse together, where neighbors "share" the outline without ever doubling the thickness, whatever the line break on resize.
Create two client utilities in Next.js:
1. "MasonryTwo": it receives children (cards);
- refs for col-a and col-b (a 2-column grid on >= 1150px, 1 on
mobile);
- distribute(matches): on desktop, item i goes to column i % 2;
otherwise all of them to col-a; at the end, an 'is-ready' class on the root
(the CSS keeps opacity 0 until is-ready so the redistribution
fails to blink);
- matchMedia('(min-width: 1150px)') with a 'change' listener
redistributes. (A CSS-first alternative: columns/grid-template-rows
masonry once support arrives; the JS distribution guarantees the
ALTERNATING order, which CSS columns fails to give.)
2. "FusedTiles": it receives children (tiles with border: 1px solid);
- each tile: width: calc(100% + 1px) and
translate: 0 calc(var(--tile-row) * -1px);
- assignRows(): it groups the tiles by Math.round(rect.y), assigns
--tile-row = theRowIndex to each one (row 0 → 0, row 1 → 1,
…) through style.setProperty;
- a ResizeObserver on the container runs assignRows (the breaks change
with the viewport).
- The result: vertical and horizontal borders fused into a continuous
1px, whatever the break.
3. Both with no scroll animation: they are composition utilities that
the reveal motions (where they exist) inherit and nothing more.Step 11 — The closing: looping testimonials and the "now playing"
Motion 13 — A looping testimonial slider
Testimonials in a continuous row of cards at natural widths (each quote at the size it asks for), draggable, in an endless loop. And an editorial decision worth recording: no autoplay, since testimonials ask for reading at the user's pace, and the audited site respects that.
Create a client component "TestimonialLoop" in Next.js with Embla:
1. useEmblaCarousel({ loop: true, align: 'start',
containScroll: false }). Slides: flex 0 0 auto, width max-content
with a max-width (36rem for example), a gap of ~50px through margins.
2. Cursor: a grab/grabbing class on the viewport during the drag
(embla exposes pointerDown/Up through events).
3. Prev/next arrows (they can use the CircleSwapButton from Motion 12 in a
compact version); with loop: true there are no ends, so the arrows
disable in no case.
4. Accessibility: a region with aria-roledescription="carousel",
slides with role="group" and "N of M" labels; announce changes in
aria-live="polite".
5. Autoplay: leave it OUT (the audited site uses none; testimonials ask for
reading at the user's pace).
6. prefers-reduced-motion: keep the slider (the user controls it),
and reduce the snap duration alone if it is configurable.Motion 15 — The "now playing" widget
In the footer, a line tells you what is playing in the studio: the track, the artist and a little dot pulsing at the rhythm of someone breathing, the universal sign of "live". On hovering the link, the icon rotates 45° with a light shrink, like a turntable button under a finger. Tiny, memorable, and the site's final lesson: the charm lies in the specificity. Labels like "playing in the studio:" make the widget feel like a person rather than a feature.
Create a "NowPlaying" component in Next.js:
1. A Server Component with the data ({ track, artist, url }) coming from
props or from a fetch with revalidate (3600 for example); a static fallback
if the source fails.
2. Markup: <p class="now-playing"> <span class="artist">…</span>
<a class="track" href={url}>… <i>↗</i></a> </p>
3. CSS:
.now-playing { display: inline-flex; align-items: baseline;
gap: .5rem .875rem; flex-wrap: wrap; }
.now-playing::before { content: ''; width: .625rem;
aspect-ratio: 1; border-radius: 50%;
background: var(--tk-color-accent); align-self: center;
animation: live-pulse 1.4s ease-out infinite; }
@keyframes live-pulse { 0%, 100% { scale: .4 } 50% { scale: 1 } }
@media (hover: hover) {
.now-playing:has(.track:is(:hover, :focus)) i {
rotate: 45deg; scale: .9;
transition: rotate .2s, scale .2s; } }
4. prefers-reduced-motion: animation: none on the pulse (a static dot).
5. The charm lies in the specificity: labels like "playing in the
studio:" make the widget feel like a person rather than a feature.The 15 traps that will bite you (read before coding)
"use client"is mandatory in every component that touches GSAP or observers.- Always
useGSAP({ scope })in place of a rawuseEffect: the automaticcontext.revert()kills orphan tweens and triggers when you navigate between routes. - Gates BEFORE initializing, rather than after: the pattern of the audited site is to mount no logic when
prefers-reduced-motion: reduceapplies, when the breakpoint fails to carry the effect, or when(hover: hover)fails; replicating that avoids paying a JS cost for effects that will run in no case. - SplitText after the fonts alone (
document.fonts.loadof the family in use, orautoSplit); a split with a fallback font means wrong line breaks. And re-split on resize (revert + recreate through a ResizeObserver), reusing lines measured at another width in no case. clip-pathwith a radius in BOTH states: animateinset(... round r)→inset(... round r)keeping theround; omitting the radius on one side makes the corner "jump" at the start of the tween.- A
scrollTriggerconfig vs. an instance: passing the same configuration object to several tweens is valid (each one creates its own trigger), but an already created instance belongs to a single tween or timeline; when in doubt, use one timeline. AndinvalidateOnRefresh: truewheneverstart/endare functions. ease: 'none'on every scrub: the smoothing comes from thescrubvalue, and from the ease in no case.- A ticker with
deltaRatio(): any movement driven by a ticker (the marquee) has to compensate for the framerate, or it will run 2x faster on 120Hz monitors. - Pause outside the viewport: every piece of continuous work turns on and off through an IntersectionObserver.
- Anchor Positioning is progressive enhancement: wrap it in
@supports (anchor-name: --a)with a dignified static fallback; the same holds for:has()in older browsers. inerton the closed overlay andaria-expandedon the trigger: the invisible menu can receive focus in no case; Escape closes; the focus returns to the trigger. It is the minimum cost of a serious overlay.- A sticky stack needs honest measurement: cards taller than the available space with sticky on create "jumps"; measure and turn the effect off when it fails to fit, the way the audited site does.
translate/scale/rotateas individual properties (modern CSS) mix withtransformon the same element only with care: pick one of the two worlds per element (GSAP writes totransform).ScrollTrigger.refresh()after images that change the layout load; scrubs with a positionalstartdepend on the final layout.prefers-reduced-motionon everything: the hero straight to the final state, a static marquee, the stacking as a list, the ink text "written" already, the pulses off.
Where to start today
The entry trio: Step 1 (the infrastructure with the three gates, which is what changes the way you write motion components), Step 2 (button and badge in pure CSS, an immediate result) and Step 4 (the nav pill with Anchor Positioning, half an hour that will teach you more modern CSS than a month of tutorials).
And with this guide the series closes an instructive triangle: Plantica proves that CSS with variables replaces close to every tween; ANAI shows how layers separate the continuous, the event and the spectacle; and Studio Modular teaches the discipline that makes it all scale: self-contained modules, gates at the door, and modern CSS solving things before JavaScript gets called in. Three sites, three philosophies, one repertoire: now pick the one for your 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


