Skip to content
Zumkai

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
The Studio Modular home page: giant dark green typography on a cream background, glass pill navigation and the "How we roll" badge
Contents
  1. The philosophy: modules that know when to sit out
  2. The stack and the role of each piece
  3. The map of the 15 motions
  4. Step 1 — The foundation: infrastructure and the three gates
  5. Step 2 — Quick wins in pure CSS: the button and the scroll badge
  6. Step 3 — The calling card: a split wordmark that assembles and comes apart
  7. Step 4 — The technical jewel: a nav pill with CSS Anchor Positioning (Motion 4)
  8. Step 5 — The accessibility pattern: a menu that blooms in a circle (Motion 5)
  9. Step 6 — The most sophisticated scrub: "ink over the draft" text (Motion 7)
  10. Step 7 — Declarative scrubs in series: parallax and circular morph
  11. Step 8 — Sticky with honesty: stacked cards with fit measurement (Motion 8)
  12. Step 9 — The marquee that obeys your scroll (Motion 6)
  13. Step 10 — The portfolio set: cursor chaser and responsive grids
  14. Step 11 — The closing: looping testimonials and the "now playing"
  15. The 15 traps that will bite you (read before coding)
  16. 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: reduce is 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

LibraryRole in the projectWhy
GSAP 3.13+ and @gsap/reactHero entrance timelines, scrubs (text, wordmark, morph, parallax), the marquee ticker, the chaser's quickToGSAP 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" revealAutomatic re-split on resize; a font gate is mandatory
Embla Carousel (or Swiper)The looping testimonial sliderThe audited site uses Swiper (slidesPerView auto, loop); Embla is the idiomatic equivalent in React
Modern CSSAnchor Positioning (the nav pill), clip-path: circle() (the menu), :has() (composed hovers), backdrop-filterA 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

#MotionTypical useTrigger
1Hero opening: a split wordmark + media that sproutsThe home heroLoad
2Wordmark separation on scrollThe home heroScroll
3A "scroll" badge with rotating circular textHeroAutomatic loop
4Sliding nav pill (Anchor Positioning)Desktop headerHover
5An overlay menu that blooms in a circleMobile/global menuClick
6A marquee responsive to the scroll directionText/logo bandsLoop + Scroll
7"Ink over the draft" text scrubA manifesto/long introScroll
8Sticky stacked cards with fit measurementA services/plans listScroll
9Media morphing into a circleA section dividerScroll
10Declarative parallax through data attributesEditorial imagesScroll
11A cursor chaser with a spinning badgeProject cardsHover
12A button with a circle that switches sidesGlobal CTAsHover
13A looping testimonial sliderSocial proofDrag/Arrows
14Responsive grids: alternating masonry + fused tilesPortfolio and link listsResize
15A "now playing" widgetFooter (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.

txt
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.

txt
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.

txt
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.

txt
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.

txt
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.

txt
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.

txt
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.

txt
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.

txt
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.

txt
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.

txt
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.

txt
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.

txt
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.

txt
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.

txt
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.

txt
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 raw useEffect: the automatic context.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: reduce applies, 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.load of the family in use, or autoSplit); 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-path with a radius in BOTH states: animate inset(... round r)inset(... round r) keeping the round; omitting the radius on one side makes the corner "jump" at the start of the tween.
  • A scrollTrigger config 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. And invalidateOnRefresh: true whenever start/end are functions.
  • ease: 'none' on every scrub: the smoothing comes from the scrub value, 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.
  • inert on the closed overlay and aria-expanded on 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/rotate as individual properties (modern CSS) mix with transform on the same element only with care: pick one of the two worlds per element (GSAP writes to transform).
  • ScrollTrigger.refresh() after images that change the layout load; scrubs with a positional start depend on the final layout.
  • prefers-reduced-motion on 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.