Sticky Split-Screen Storytelling
A two-column feature: the left visual column is sticky for the whole section while four text chapters scroll by on the right; each chapter crossing the centre swaps the visual with a directional crossfade and lights its progress dot.
- Layout
- gsap
- scrolltrigger
- intermediate
- light perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1394 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Sticky Split-Screen Storytelling
**What it does (visual):** After a full-height intro ("Four chapters of one river"), the page becomes two columns. The left half is a rounded 16px frame filling the viewport height that *stays in place* while the page scrolls; it shows a procedural landscape — a soft radial glow and a luminous disc over a two-tone vertical gradient with a thin horizon line and film grain — plus a huge light-serif chapter number ("01") in the bottom-left and a tiny mono caption ("Source · 1,840 m") bottom-right. A column of four small dots at the left edge marks progress; the active dot is a 26px pill in the accent colour (#d4ff3a). The right half holds four chapters, each a full viewport tall, with a mono eyebrow in the same accent ("Chapter 02 — Gorge"), a large serif headline and a short muted paragraph that rise 30px into view as the chapter approaches. The moment a chapter's top passes the sensor line of the viewport (`center`), the left image swaps: the old frame fades out over 0.7s and drifts up three quarters of 8% while scaling to 102%, the new frame fades in over 0.9s from 8% below while settling from 1.06× scale, its chapter number rising in a beat later. Scrolling back up plays the same swap downward. The image changes four times; the frame never moves. After the fourth chapter the sticky column releases and an outro section scrolls in normally. Under 768px the sticky column disappears and each chapter shows its own 4:3 inline image above the text.
**Tech stack:**
- GSAP 3.13+ and ScrollTrigger.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);`
- The sticky behaviour is pure CSS (`position: sticky`); ScrollTrigger is only a sensor. Works with Lenis if present.
**HTML structure:**
```html
<section class="sst-intro"><p class="sst-intro__label">Layout · Sticky split</p><h1 class="sst-intro__title">Four chapters of one river</h1><p class="sst-intro__hint">Scroll ↓</p></section>
<section class="sst">
<div class="sst__visual">
<div class="sst__stage">
<figure class="sst__frame is-active" style="--c1:#dfe9f3;--c2:#7fa6c9;--c3:#1b2a3d;--x:68%;--y:30%;--h:62%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">01</span>
<figcaption class="sst__cap">Source · 1,840 m</figcaption>
</figure>
<!-- 3 more .sst__frame (02 Gorge, 03 Delta, 04 Sea) with their own --c1/--c2/--c3/--x/--y/--h -->
</div>
<ol class="sst__dots" aria-hidden="true"><li class="is-active"></li><li></li><li></li><li></li></ol>
</div>
<div class="sst__chapters">
<article class="sst__chapter">
<figure class="sst__inline" style="/* same vars as frame 01 */"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">01</span></figure>
<p class="sst__eyebrow">Chapter 01 — Source</p>
<h2 class="sst__h">It begins as weather</h2>
<p class="sst__p">Snow that fell in March lets go in June…</p>
</article>
<!-- 3 more .sst__chapter -->
</div>
</section>
<section class="sst-outro"><p class="sst-outro__text">End of the feature.</p></section>
```
Per-frame variables: `--c1` glow/disc colour, `--c2` sky, `--c3` ground, `--x/--y` disc position, `--h` horizon height.
**CSS requirements:**
- `.sst-intro, .sst-outro`: `min-height: 100vh; display: grid; place-content: center; text-align: center; padding: 6vw;` title: display serif 300, `font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92`.
- `.sst`: `display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); align-items: start;` — `align-items: start` is REQUIRED or the sticky child is stretched and never sticks. No `overflow: hidden` on any ancestor.
- `.sst__visual`: `position: sticky; top: 0; height: 100vh; padding: clamp(1rem, 3vw, 2.5rem);`
- `.sst__stage`: `position: relative; height: 100%; border-radius: 16px; overflow: hidden; background: var(--surface); isolation: isolate;`
- `.sst__frame`: `position: absolute; inset: 0; margin: 0; visibility: hidden; opacity: 0; will-change: transform, opacity;` `.is-active { visibility: visible; opacity: 1; z-index: 1 }`.
- `.sst__art`: `position: absolute; inset: 0;` background = `radial-gradient(55% 45% at var(--x) var(--y), var(--c1) 0%, transparent 65%), linear-gradient(180deg, var(--c2) 0%, var(--c3) var(--h), color-mix(in srgb, var(--c3) 70%, #000) 100%)`. `::before` disc: `left: var(--x); top: var(--y); width: 18%; aspect-ratio: 1; translate(-50%,-50%); border-radius: 50%; background: radial-gradient(circle at 38% 36%, #fff 0%, var(--c1) 55%, transparent 72%)`. `::after` grain: inline SVG `feTurbulence` data-URI, `opacity: .45; mix-blend-mode: overlay`. `.sst__frame::before` horizon: `top: var(--h); height: 1px; background: color-mix(in srgb, var(--c1) 40%, transparent)`.
- `.sst__num`: `position: absolute; left: clamp(1rem, 2.5vw, 2rem); bottom: clamp(.5rem, 1.5vw, 1rem); font-family: var(--font-display); font-weight: 300; font-size: clamp(5rem, 13vw, 13rem); line-height: .8; letter-spacing: -0.06em; color: #fff; mix-blend-mode: overlay; opacity: .9;` `.sst__cap`: mono 11px uppercase, bottom-right, `color: #fff; opacity: .75`.
- `.sst__dots`: `position: absolute; left: clamp(2rem, 5vw, 4rem); top: 50%; transform: translateY(-50%); display: grid; gap: 10px; z-index: 2;` `li { width: 6px; height: 6px; border-radius: 3px; background: rgba(255,255,255,.35); transition: height .45s cubic-bezier(.2,.7,.1,1), background .3s }` `li.is-active { height: 26px; background: var(--accent) }`. Accent colour (dots, eyebrows): `--accent: #d4ff3a`.
- `.sst__chapter`: `min-height: 100vh; display: grid; align-content: center; padding: clamp(2rem, 6vw, 5rem) clamp(1.25rem, 6vw, 6rem) clamp(2rem, 6vw, 5rem) clamp(1.25rem, 4vw, 4rem);` `.sst__eyebrow` mono 11px uppercase `color: var(--accent)`; `.sst__h` display serif 300 `font-size: clamp(2.25rem, 4.5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95; max-width: 12ch`; `.sst__p` muted, `max-width: 42ch; line-height: 1.55`.
- `.sst__inline { display: none }` on desktop.
**Animation spec (exact values):**
1. Intro: `gsap.from('.sst-intro > *', { y: 30, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.1, delay: 0.1 })`.
2. Inside `gsap.matchMedia().add('(min-width: 768px)', …)`: `gsap.set(frames, { autoAlpha: (i) => i === 0 ? 1 : 0 })`, then one sensor per chapter:
```js
ScrollTrigger.create({ trigger: chapter, start: 'top center', end: 'bottom center', markers: false,
onToggle: (self) => { if (self.isActive) activate(i); } });
```
3. `activate(i)` (ignored if `i === current`): `dir = i > current ? 1 : -1`; toggle `.is-active` on dots and frames; `gsap.set(next, { zIndex: 2 }); gsap.set(prev, { zIndex: 1 });` then a timeline:
- `tl.to(prev, { autoAlpha: 0, yPercent: -8 * 0.75 * dir, scale: 1.02, duration: 0.7, ease: 'power3.inOut', overwrite: 'auto' }, 0)` — outgoing frame (drifts three quarters of the incoming travel).
- `tl.fromTo(next, { autoAlpha: 0, yPercent: 8 * dir, scale: 1.06 }, { autoAlpha: 1, yPercent: 0, scale: 1, duration: 0.9, ease: 'power3.out', overwrite: 'auto' }, 0.1)` — incoming frame.
- `tl.fromTo(next.querySelector('.sst__num'), { yPercent: 30 * dir, opacity: 0 }, { yPercent: 0, opacity: 0.9, duration: 0.8, ease: 'power3.out' }, 0.25)` — chapter number.
- `tl.fromTo(next.querySelector('.sst__cap'), { opacity: 0 }, { opacity: 0.75, duration: 0.5, ease: 'power2.out' }, 0.4)` — caption.
4. Chapter copy reveal (all breakpoints): `gsap.from(chapter.querySelectorAll('.sst__eyebrow, .sst__h, .sst__p'), { y: 30, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08, scrollTrigger: { trigger: chapter, start: 'top 75%', toggleActions: 'play none none reverse' } })`.
5. `document.fonts.ready.then(() => ScrollTrigger.refresh())`.
**Interaction:** None beyond scrolling; the visual follows the chapter whose box straddles the viewport sensor line (`center`), in both directions. The dot column is decorative (`aria-hidden`).
**Responsive behavior:** Under 768px (`@media (max-width: 767px)` + the matchMedia guard): `.sst { grid-template-columns: 1fr }`, `.sst__visual { display: none }` (no sensors are created), `.sst__chapter { min-height: 0; padding: 3rem 1.25rem }`, and `.sst__inline { display: block; aspect-ratio: 4/3; border-radius: 12px; overflow: hidden; margin-bottom: 1.75rem }` with its number at `5rem`. The chapter copy reveal still runs. All type is `clamp()`-based so 375px is clean.
**Accessibility:** The frames are `<figure>`s with `<figcaption>` captions; numbers and art are `aria-hidden`. Chapters are `<article>`s with `<h2>` headings. With `prefers-reduced-motion: reduce`: no intro, no copy reveal, and `activate()` swaps frames instantly with `gsap.set` (the correct image still follows the chapter — that is state, not motion); dot transitions are disabled.
**Performance rules:** Sticky positioning is handled by the compositor; only `transform`/`opacity` are animated during swaps (`will-change: transform, opacity` on frames). Four lightweight ScrollTriggers with no `scrub` and no `pin` — no pin-spacer layout. `overwrite: 'auto'` keeps fast scrolls from stacking swaps. Everything lives in `gsap.context()` and `ctx.revert()` on unmount (matchMedia contexts are reverted with it).
**Customization knobs:**
- `SWAP_OUT` (0.7) / `SWAP_IN` (0.9) — swap tempo.
- Travel (`TRAVEL = 8` → incoming `yPercent ±8`, outgoing ±0.75×) and incoming scale (`ZOOM = 1.06`; outgoing 1.02) — set them to 0 / 1 for a pure crossfade.
- Sensor line (`SENSOR = 'center'`): `'top center' / 'bottom center'` — use `40%` to switch earlier, `60%` later.
- Per-frame `--c1/--c2/--c3/--x/--y/--h` for the artwork; replace `.sst__art` with an `<img>` in production.
- Dot / eyebrow colour `--accent` (#d4ff3a).
**Acceptance criteria:**
- [ ] The left frame stays fixed on screen for the entire four-chapter section and releases after it; the intro and outro scroll normally.
- [ ] Each chapter swaps the image exactly when its top crosses the viewport sensor line (`center`); scrolling back swaps in the opposite direction.
- [ ] Only one frame is visible at rest; the incoming frame is always on top during a swap.
- [ ] The active dot is the #d4ff3a pill and matches the visible chapter.
- [ ] Under 768px there is no sticky column; each chapter shows its inline image.
- [ ] Reduced motion: instant swaps, no reveals; no console errors. Add this effect to my existing site/section: **Sticky Split-Screen Storytelling**. Keep my chapters' copy and images. Wrap them in a two-column `.sst` grid (`align-items: start`): a left `.sst__visual` with `position: sticky; top: 0; height: 100vh` containing one absolutely-stacked `.sst__frame` per chapter, and a right `.sst__chapters` column where each `.sst__chapter` is `min-height: 100vh`. With GSAP ScrollTrigger create one sensor per chapter (`start: 'top center'`, `end: 'bottom center'`, `onToggle: self => self.isActive && activate(i)`). In `activate(i)` tween the previous frame `autoAlpha → 0, yPercent → -(8 × 0.75)·dir, scale → 1.02` (0.7s, `power3.inOut`) and the next frame `fromTo { autoAlpha: 0, yPercent: 8·dir, scale: 1.06 } → { autoAlpha: 1, yPercent: 0, scale: 1 }` (0.9s, `power3.out`, at 0.1), where `dir` is +1 scrolling down and −1 scrolling up; update a dot indicator coloured `#d4ff3a`. Reveal each chapter's text with `from { y: 30, opacity: 0 }` (0.9s, `power3.out`, stagger 0.08, `start: 'top 75%'`). Under 768px hide the sticky column and show inline images; respect `prefers-reduced-motion` with instant swaps. Wrap everything in `gsap.context()` and revert on unmount. Do not change my fonts or colours. ### EFFECT (React / Next.js): Sticky Split-Screen Storytelling
Build **Sticky Split-Screen Storytelling** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/StickySplitScreenStorytelling.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, ScrollTrigger);
export default function StickySplitScreenStorytelling() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="sst-intro">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`StickySplitScreenStorytelling.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### EFFECT: Sticky Split-Screen Storytelling
**What it does (visual):** After a full-height intro ("Four chapters of one river"), the page becomes two columns. The left half is a rounded 16px frame filling the viewport height that *stays in place* while the page scrolls; it shows a procedural landscape — a soft radial glow and a luminous disc over a two-tone vertical gradient with a thin horizon line and film grain — plus a huge light-serif chapter number ("01") in the bottom-left and a tiny mono caption ("Source · 1,840 m") bottom-right. A column of four small dots at the left edge marks progress; the active dot is a 26px pill in the accent colour (#d4ff3a). The right half holds four chapters, each a full viewport tall, with a mono eyebrow in the same accent ("Chapter 02 — Gorge"), a large serif headline and a short muted paragraph that rise 30px into view as the chapter approaches. The moment a chapter's top passes the sensor line of the viewport (`center`), the left image swaps: the old frame fades out over 0.7s and drifts up three quarters of 8% while scaling to 102%, the new frame fades in over 0.9s from 8% below while settling from 1.06× scale, its chapter number rising in a beat later. Scrolling back up plays the same swap downward. The image changes four times; the frame never moves. After the fourth chapter the sticky column releases and an outro section scrolls in normally. Under 768px the sticky column disappears and each chapter shows its own 4:3 inline image above the text.
**Tech stack:**
- GSAP 3.13+ and ScrollTrigger.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);`
- The sticky behaviour is pure CSS (`position: sticky`); ScrollTrigger is only a sensor. Works with Lenis if present.
**HTML structure:**
```html
<section class="sst-intro"><p class="sst-intro__label">Layout · Sticky split</p><h1 class="sst-intro__title">Four chapters of one river</h1><p class="sst-intro__hint">Scroll ↓</p></section>
<section class="sst">
<div class="sst__visual">
<div class="sst__stage">
<figure class="sst__frame is-active" style="--c1:#dfe9f3;--c2:#7fa6c9;--c3:#1b2a3d;--x:68%;--y:30%;--h:62%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">01</span>
<figcaption class="sst__cap">Source · 1,840 m</figcaption>
</figure>
<!-- 3 more .sst__frame (02 Gorge, 03 Delta, 04 Sea) with their own --c1/--c2/--c3/--x/--y/--h -->
</div>
<ol class="sst__dots" aria-hidden="true"><li class="is-active"></li><li></li><li></li><li></li></ol>
</div>
<div class="sst__chapters">
<article class="sst__chapter">
<figure class="sst__inline" style="/* same vars as frame 01 */"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">01</span></figure>
<p class="sst__eyebrow">Chapter 01 — Source</p>
<h2 class="sst__h">It begins as weather</h2>
<p class="sst__p">Snow that fell in March lets go in June…</p>
</article>
<!-- 3 more .sst__chapter -->
</div>
</section>
<section class="sst-outro"><p class="sst-outro__text">End of the feature.</p></section>
```
Per-frame variables: `--c1` glow/disc colour, `--c2` sky, `--c3` ground, `--x/--y` disc position, `--h` horizon height.
**CSS requirements:**
- `.sst-intro, .sst-outro`: `min-height: 100vh; display: grid; place-content: center; text-align: center; padding: 6vw;` title: display serif 300, `font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92`.
- `.sst`: `display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); align-items: start;` — `align-items: start` is REQUIRED or the sticky child is stretched and never sticks. No `overflow: hidden` on any ancestor.
- `.sst__visual`: `position: sticky; top: 0; height: 100vh; padding: clamp(1rem, 3vw, 2.5rem);`
- `.sst__stage`: `position: relative; height: 100%; border-radius: 16px; overflow: hidden; background: var(--surface); isolation: isolate;`
- `.sst__frame`: `position: absolute; inset: 0; margin: 0; visibility: hidden; opacity: 0; will-change: transform, opacity;` `.is-active { visibility: visible; opacity: 1; z-index: 1 }`.
- `.sst__art`: `position: absolute; inset: 0;` background = `radial-gradient(55% 45% at var(--x) var(--y), var(--c1) 0%, transparent 65%), linear-gradient(180deg, var(--c2) 0%, var(--c3) var(--h), color-mix(in srgb, var(--c3) 70%, #000) 100%)`. `::before` disc: `left: var(--x); top: var(--y); width: 18%; aspect-ratio: 1; translate(-50%,-50%); border-radius: 50%; background: radial-gradient(circle at 38% 36%, #fff 0%, var(--c1) 55%, transparent 72%)`. `::after` grain: inline SVG `feTurbulence` data-URI, `opacity: .45; mix-blend-mode: overlay`. `.sst__frame::before` horizon: `top: var(--h); height: 1px; background: color-mix(in srgb, var(--c1) 40%, transparent)`.
- `.sst__num`: `position: absolute; left: clamp(1rem, 2.5vw, 2rem); bottom: clamp(.5rem, 1.5vw, 1rem); font-family: var(--font-display); font-weight: 300; font-size: clamp(5rem, 13vw, 13rem); line-height: .8; letter-spacing: -0.06em; color: #fff; mix-blend-mode: overlay; opacity: .9;` `.sst__cap`: mono 11px uppercase, bottom-right, `color: #fff; opacity: .75`.
- `.sst__dots`: `position: absolute; left: clamp(2rem, 5vw, 4rem); top: 50%; transform: translateY(-50%); display: grid; gap: 10px; z-index: 2;` `li { width: 6px; height: 6px; border-radius: 3px; background: rgba(255,255,255,.35); transition: height .45s cubic-bezier(.2,.7,.1,1), background .3s }` `li.is-active { height: 26px; background: var(--accent) }`. Accent colour (dots, eyebrows): `--accent: #d4ff3a`.
- `.sst__chapter`: `min-height: 100vh; display: grid; align-content: center; padding: clamp(2rem, 6vw, 5rem) clamp(1.25rem, 6vw, 6rem) clamp(2rem, 6vw, 5rem) clamp(1.25rem, 4vw, 4rem);` `.sst__eyebrow` mono 11px uppercase `color: var(--accent)`; `.sst__h` display serif 300 `font-size: clamp(2.25rem, 4.5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95; max-width: 12ch`; `.sst__p` muted, `max-width: 42ch; line-height: 1.55`.
- `.sst__inline { display: none }` on desktop.
**Animation spec (exact values):**
1. Intro: `gsap.from('.sst-intro > *', { y: 30, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.1, delay: 0.1 })`.
2. Inside `gsap.matchMedia().add('(min-width: 768px)', …)`: `gsap.set(frames, { autoAlpha: (i) => i === 0 ? 1 : 0 })`, then one sensor per chapter:
```js
ScrollTrigger.create({ trigger: chapter, start: 'top center', end: 'bottom center', markers: false,
onToggle: (self) => { if (self.isActive) activate(i); } });
```
3. `activate(i)` (ignored if `i === current`): `dir = i > current ? 1 : -1`; toggle `.is-active` on dots and frames; `gsap.set(next, { zIndex: 2 }); gsap.set(prev, { zIndex: 1 });` then a timeline:
- `tl.to(prev, { autoAlpha: 0, yPercent: -8 * 0.75 * dir, scale: 1.02, duration: 0.7, ease: 'power3.inOut', overwrite: 'auto' }, 0)` — outgoing frame (drifts three quarters of the incoming travel).
- `tl.fromTo(next, { autoAlpha: 0, yPercent: 8 * dir, scale: 1.06 }, { autoAlpha: 1, yPercent: 0, scale: 1, duration: 0.9, ease: 'power3.out', overwrite: 'auto' }, 0.1)` — incoming frame.
- `tl.fromTo(next.querySelector('.sst__num'), { yPercent: 30 * dir, opacity: 0 }, { yPercent: 0, opacity: 0.9, duration: 0.8, ease: 'power3.out' }, 0.25)` — chapter number.
- `tl.fromTo(next.querySelector('.sst__cap'), { opacity: 0 }, { opacity: 0.75, duration: 0.5, ease: 'power2.out' }, 0.4)` — caption.
4. Chapter copy reveal (all breakpoints): `gsap.from(chapter.querySelectorAll('.sst__eyebrow, .sst__h, .sst__p'), { y: 30, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08, scrollTrigger: { trigger: chapter, start: 'top 75%', toggleActions: 'play none none reverse' } })`.
5. `document.fonts.ready.then(() => ScrollTrigger.refresh())`.
**Interaction:** None beyond scrolling; the visual follows the chapter whose box straddles the viewport sensor line (`center`), in both directions. The dot column is decorative (`aria-hidden`).
**Responsive behavior:** Under 768px (`@media (max-width: 767px)` + the matchMedia guard): `.sst { grid-template-columns: 1fr }`, `.sst__visual { display: none }` (no sensors are created), `.sst__chapter { min-height: 0; padding: 3rem 1.25rem }`, and `.sst__inline { display: block; aspect-ratio: 4/3; border-radius: 12px; overflow: hidden; margin-bottom: 1.75rem }` with its number at `5rem`. The chapter copy reveal still runs. All type is `clamp()`-based so 375px is clean.
**Accessibility:** The frames are `<figure>`s with `<figcaption>` captions; numbers and art are `aria-hidden`. Chapters are `<article>`s with `<h2>` headings. With `prefers-reduced-motion: reduce`: no intro, no copy reveal, and `activate()` swaps frames instantly with `gsap.set` (the correct image still follows the chapter — that is state, not motion); dot transitions are disabled.
**Performance rules:** Sticky positioning is handled by the compositor; only `transform`/`opacity` are animated during swaps (`will-change: transform, opacity` on frames). Four lightweight ScrollTriggers with no `scrub` and no `pin` — no pin-spacer layout. `overwrite: 'auto'` keeps fast scrolls from stacking swaps. Everything lives in `gsap.context()` and `ctx.revert()` on unmount (matchMedia contexts are reverted with it).
**Customization knobs:**
- `SWAP_OUT` (0.7) / `SWAP_IN` (0.9) — swap tempo.
- Travel (`TRAVEL = 8` → incoming `yPercent ±8`, outgoing ±0.75×) and incoming scale (`ZOOM = 1.06`; outgoing 1.02) — set them to 0 / 1 for a pure crossfade.
- Sensor line (`SENSOR = 'center'`): `'top center' / 'bottom center'` — use `40%` to switch earlier, `60%` later.
- Per-frame `--c1/--c2/--c3/--x/--y/--h` for the artwork; replace `.sst__art` with an `<img>` in production.
- Dot / eyebrow colour `--accent` (#d4ff3a).
**Acceptance criteria:**
- [ ] The left frame stays fixed on screen for the entire four-chapter section and releases after it; the intro and outro scroll normally.
- [ ] Each chapter swaps the image exactly when its top crosses the viewport sensor line (`center`); scrolling back swaps in the opposite direction.
- [ ] Only one frame is visible at rest; the incoming frame is always on top during a swap.
- [ ] The active dot is the #d4ff3a pill and matches the visible chapter.
- [ ] Under 768px there is no sticky column; each chapter shows its inline image.
- [ ] Reduced motion: instant swaps, no reveals; no console errors.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<section class="sst-intro">
<p class="sst-intro__label">Layout · Sticky split</p>
<h1 class="sst-intro__title">Four chapters of one river</h1>
<p class="sst-intro__hint">Scroll ↓</p>
</section>
<section class="sst">
<div class="sst__visual">
<div class="sst__stage">
<figure class="sst__frame is-active" style="--c1:#dfe9f3;--c2:#7fa6c9;--c3:#1b2a3d;--x:68%;--y:30%;--h:62%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">01</span>
<figcaption class="sst__cap">Source · 1,840 m</figcaption>
</figure>
<figure class="sst__frame" style="--c1:#5fd1b8;--c2:#0f4a44;--c3:#06161a;--x:30%;--y:26%;--h:70%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">02</span>
<figcaption class="sst__cap">Gorge · 910 m</figcaption>
</figure>
<figure class="sst__frame" style="--c1:#f0c27a;--c2:#9a5a2b;--c3:#2a1a10;--x:60%;--y:36%;--h:58%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">03</span>
<figcaption class="sst__cap">Delta · 12 m</figcaption>
</figure>
<figure class="sst__frame" style="--c1:#9aa8ff;--c2:#1e2a6b;--c3:#070a1c;--x:50%;--y:22%;--h:66%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">04</span>
<figcaption class="sst__cap">Sea · 0 m</figcaption>
</figure>
</div>
<ol class="sst__dots" aria-hidden="true"><li class="is-active"></li><li></li><li></li><li></li></ol>
</div>
<div class="sst__chapters">
<article class="sst__chapter">
<figure class="sst__inline" style="--c1:#dfe9f3;--c2:#7fa6c9;--c3:#1b2a3d;--x:68%;--y:30%;--h:62%"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">01</span></figure>
<p class="sst__eyebrow">Chapter 01 — Source</p>
<h2 class="sst__h">It begins as weather</h2>
<p class="sst__p">Snow that fell in March lets go in June. Under the glacier's lip the meltwater is the colour of milk glass, loud, and barely a stride wide. Nothing here suggests the thing it will become.</p>
</article>
<article class="sst__chapter">
<figure class="sst__inline" style="--c1:#5fd1b8;--c2:#0f4a44;--c3:#06161a;--x:30%;--y:26%;--h:70%"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">02</span></figure>
<p class="sst__eyebrow">Chapter 02 — Gorge</p>
<h2 class="sst__h">The mountain gives way</h2>
<p class="sst__p">For eleven kilometres the river has cut a slot so narrow the sun reaches the bottom for forty minutes a day. The water turns the green of old bottles and moves faster than you can walk.</p>
</article>
<article class="sst__chapter">
<figure class="sst__inline" style="--c1:#f0c27a;--c2:#9a5a2b;--c3:#2a1a10;--x:60%;--y:36%;--h:58%"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">03</span></figure>
<p class="sst__eyebrow">Chapter 03 — Delta</p>
<h2 class="sst__h">It slows, and spreads</h2>
<p class="sst__p">Released onto the plain, the river forgets its hurry. It braids into channels, drops the silt it carried for three hundred kilometres and builds the fields that feed four towns.</p>
</article>
<article class="sst__chapter">
<figure class="sst__inline" style="--c1:#9aa8ff;--c2:#1e2a6b;--c3:#070a1c;--x:50%;--y:22%;--h:66%"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">04</span></figure>
<p class="sst__eyebrow">Chapter 04 — Sea</p>
<h2 class="sst__h">Then it is not a river</h2>
<p class="sst__p">At the mouth the fresh water rides out over the salt for almost a mile before it mixes. From the air you can see the line. Then the tide turns, and there is no line at all.</p>
</article>
</div>
</section>
<section class="sst-outro">
<p class="sst-outro__text">End of the feature — the page continues normally.</p>
</section>
```
```css
.sst-intro, .sst-outro { min-height: 100vh; display: grid; place-content: center; text-align: center; padding: 6vw; }
.sst-intro__label, .sst-intro__hint { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.sst-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92; margin: 1.5rem 0 2.5rem; max-width: 12ch; }
.sst-outro__text { color: var(--muted); max-width: 40ch; }
.sst { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
.sst__visual { position: sticky; top: 0; height: 100vh; padding: clamp(1rem, 3vw, 2.5rem); }
.sst__stage { position: relative; height: 100%; border-radius: 16px; overflow: hidden; background: var(--surface); isolation: isolate; }
.sst__frame { position: absolute; inset: 0; margin: 0; visibility: hidden; opacity: 0; will-change: transform, opacity; }
.sst__frame.is-active { visibility: visible; opacity: 1; z-index: 1; }
.sst__art {
position: absolute; inset: 0;
background:
radial-gradient(55% 45% at var(--x) var(--y), var(--c1) 0%, transparent 65%),
linear-gradient(180deg, var(--c2) 0%, var(--c3) var(--h), color-mix(in srgb, var(--c3) 70%, #000) 100%);
}
.sst__art::before { content: ''; position: absolute; left: var(--x); top: var(--y); width: 18%; aspect-ratio: 1; transform: translate(-50%, -50%); border-radius: 50%; background: radial-gradient(circle at 38% 36%, #fff 0%, var(--c1) 55%, transparent 72%); filter: blur(.5px); }
.sst__art::after {
content: ''; position: absolute; inset: 0; opacity: .45; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.sst__frame::before { content: ''; position: absolute; left: 0; right: 0; top: var(--h); height: 1px; background: color-mix(in srgb, var(--c1) 40%, transparent); }
.sst__num { position: absolute; left: clamp(1rem, 2.5vw, 2rem); bottom: clamp(.5rem, 1.5vw, 1rem); font-family: var(--font-display); font-weight: 300; font-size: clamp(5rem, 13vw, 13rem); line-height: .8; letter-spacing: -0.06em; color: #fff; mix-blend-mode: overlay; opacity: .9; }
.sst__cap { position: absolute; right: clamp(1rem, 2.5vw, 2rem); bottom: clamp(1rem, 2.5vw, 2rem); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: #fff; opacity: .75; }
.sst__dots { position: absolute; left: clamp(2rem, 5vw, 4rem); top: 50%; transform: translateY(-50%); display: grid; gap: 10px; list-style: none; z-index: 2; }
.sst__dots li { width: 6px; height: 6px; border-radius: 3px; background: rgba(255,255,255,.35); transition: height .45s cubic-bezier(.2,.7,.1,1), background .3s; }
.sst__dots li.is-active { height: 26px; background: var(--knob-accent, var(--accent)); }
.sst__chapters { display: flex; flex-direction: column; }
.sst__chapter { min-height: 100vh; display: grid; align-content: center; padding: clamp(2rem, 6vw, 5rem) clamp(1.25rem, 6vw, 6rem) clamp(2rem, 6vw, 5rem) clamp(1.25rem, 4vw, 4rem); }
.sst__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--knob-accent, var(--accent)); margin-bottom: 1.25rem; }
.sst__h { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.25rem, 4.5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95; max-width: 12ch; }
.sst__p { margin-top: 1.5rem; max-width: 42ch; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.55; }
.sst__inline { display: none; }
@media (max-width: 767px) {
.sst { grid-template-columns: 1fr; }
.sst__visual { display: none; }
.sst__chapter { min-height: 0; padding: 3rem 1.25rem; }
.sst__inline { display: block; position: relative; margin: 0 0 1.75rem; aspect-ratio: 4 / 3; border-radius: 12px; overflow: hidden; isolation: isolate; }
.sst__inline .sst__num { font-size: 5rem; left: 1rem; bottom: .5rem; }
}
@media (prefers-reduced-motion: reduce) {
.sst__dots li { transition: none; }
}
```
```js
// Sticky Split-Screen Storytelling — sticky visual column, ScrollTrigger as a chapter sensor.
gsap.registerPlugin(ScrollTrigger);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { swapOut: 0.7, swapIn: 0.9, travel: 8, zoom: 1.06, sensor: 'center', accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.sst');
const frames = gsap.utils.toArray('.sst__frame');
const chapters = gsap.utils.toArray('.sst__chapter');
const dots = gsap.utils.toArray('.sst__dots li');
let ctx;
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
const SWAP_OUT = K('swapOut'); // outgoing frame
const SWAP_IN = K('swapIn'); // incoming frame
const TRAVEL = K('travel'); // incoming yPercent; the outgoing frame drifts 3/4 of it
const ZOOM = K('zoom'); // incoming start scale
const SENSOR = K('sensor'); // viewport line that selects the chapter
// The active chapter lives in the classes, so a rebuild picks up where the reader is.
let current = Math.max(0, frames.findIndex((f) => f.classList.contains('is-active')));
ctx = gsap.context(() => {
if (!reduced) gsap.from('.sst-intro > *', { y: 30, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.1, delay: 0.1 });
function activate(i) {
if (i === current) return;
const dir = i > current ? 1 : -1; // 1 = reading forward
const prev = frames[current];
const next = frames[i];
current = i;
dots.forEach((d, k) => d.classList.toggle('is-active', k === i));
frames.forEach((f, k) => f.classList.toggle('is-active', k === i));
gsap.set(next, { zIndex: 2 });
gsap.set(prev, { zIndex: 1 });
if (reduced) {
gsap.set(prev, { autoAlpha: 0 });
gsap.set(next, { autoAlpha: 1, yPercent: 0, scale: 1 });
return;
}
ctx.add(() => gsap.timeline() // recorded in the context so a Playground rebuild reverts an in-flight swap
.to(prev, { autoAlpha: 0, yPercent: -TRAVEL * 0.75 * dir, scale: 1.02, duration: SWAP_OUT, ease: 'power3.inOut', overwrite: 'auto' }, 0)
.fromTo(next, { autoAlpha: 0, yPercent: TRAVEL * dir, scale: ZOOM }, { autoAlpha: 1, yPercent: 0, scale: 1, duration: SWAP_IN, ease: 'power3.out', overwrite: 'auto' }, 0.1)
.fromTo(next.querySelector('.sst__num'), { yPercent: 30 * dir, opacity: 0 }, { yPercent: 0, opacity: 0.9, duration: 0.8, ease: 'power3.out' }, 0.25)
.fromTo(next.querySelector('.sst__cap'), { opacity: 0 }, { opacity: 0.75, duration: 0.5, ease: 'power2.out' }, 0.4));
}
const mm = gsap.matchMedia();
// Desktop: sticky visual + chapter sensors
mm.add('(min-width: 768px)', () => {
gsap.set(frames, { autoAlpha: (i) => (i === current ? 1 : 0), yPercent: 0, scale: 1 });
chapters.forEach((chapter, i) => {
ScrollTrigger.create({
trigger: chapter, start: `top ${SENSOR}`, end: `bottom ${SENSOR}`, markers: false,
onToggle: (self) => { if (self.isActive) activate(i); },
});
});
});
// Chapter copy reveal (all sizes)
if (!reduced) {
chapters.forEach((chapter) => {
gsap.from(chapter.querySelectorAll('.sst__eyebrow, .sst__h, .sst__p'), {
y: 30, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08,
scrollTrigger: { trigger: chapter, start: 'top 75%', toggleActions: 'play none none reverse' },
});
});
}
document.fonts.ready.then(() => ScrollTrigger.refresh());
});
}
build();
// Playground: rebuild (sensors are re-created at the new line) with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (single HTML file): Sticky Split-Screen Storytelling
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Sticky Split-Screen Storytelling** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sticky Split-Screen Storytelling</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### EFFECT: Sticky Split-Screen Storytelling
**What it does (visual):** After a full-height intro ("Four chapters of one river"), the page becomes two columns. The left half is a rounded 16px frame filling the viewport height that *stays in place* while the page scrolls; it shows a procedural landscape — a soft radial glow and a luminous disc over a two-tone vertical gradient with a thin horizon line and film grain — plus a huge light-serif chapter number ("01") in the bottom-left and a tiny mono caption ("Source · 1,840 m") bottom-right. A column of four small dots at the left edge marks progress; the active dot is a 26px pill in the accent colour (#d4ff3a). The right half holds four chapters, each a full viewport tall, with a mono eyebrow in the same accent ("Chapter 02 — Gorge"), a large serif headline and a short muted paragraph that rise 30px into view as the chapter approaches. The moment a chapter's top passes the sensor line of the viewport (`center`), the left image swaps: the old frame fades out over 0.7s and drifts up three quarters of 8% while scaling to 102%, the new frame fades in over 0.9s from 8% below while settling from 1.06× scale, its chapter number rising in a beat later. Scrolling back up plays the same swap downward. The image changes four times; the frame never moves. After the fourth chapter the sticky column releases and an outro section scrolls in normally. Under 768px the sticky column disappears and each chapter shows its own 4:3 inline image above the text.
**Tech stack:**
- GSAP 3.13+ and ScrollTrigger.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);`
- The sticky behaviour is pure CSS (`position: sticky`); ScrollTrigger is only a sensor. Works with Lenis if present.
**HTML structure:**
```html
<section class="sst-intro"><p class="sst-intro__label">Layout · Sticky split</p><h1 class="sst-intro__title">Four chapters of one river</h1><p class="sst-intro__hint">Scroll ↓</p></section>
<section class="sst">
<div class="sst__visual">
<div class="sst__stage">
<figure class="sst__frame is-active" style="--c1:#dfe9f3;--c2:#7fa6c9;--c3:#1b2a3d;--x:68%;--y:30%;--h:62%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">01</span>
<figcaption class="sst__cap">Source · 1,840 m</figcaption>
</figure>
<!-- 3 more .sst__frame (02 Gorge, 03 Delta, 04 Sea) with their own --c1/--c2/--c3/--x/--y/--h -->
</div>
<ol class="sst__dots" aria-hidden="true"><li class="is-active"></li><li></li><li></li><li></li></ol>
</div>
<div class="sst__chapters">
<article class="sst__chapter">
<figure class="sst__inline" style="/* same vars as frame 01 */"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">01</span></figure>
<p class="sst__eyebrow">Chapter 01 — Source</p>
<h2 class="sst__h">It begins as weather</h2>
<p class="sst__p">Snow that fell in March lets go in June…</p>
</article>
<!-- 3 more .sst__chapter -->
</div>
</section>
<section class="sst-outro"><p class="sst-outro__text">End of the feature.</p></section>
```
Per-frame variables: `--c1` glow/disc colour, `--c2` sky, `--c3` ground, `--x/--y` disc position, `--h` horizon height.
**CSS requirements:**
- `.sst-intro, .sst-outro`: `min-height: 100vh; display: grid; place-content: center; text-align: center; padding: 6vw;` title: display serif 300, `font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92`.
- `.sst`: `display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); align-items: start;` — `align-items: start` is REQUIRED or the sticky child is stretched and never sticks. No `overflow: hidden` on any ancestor.
- `.sst__visual`: `position: sticky; top: 0; height: 100vh; padding: clamp(1rem, 3vw, 2.5rem);`
- `.sst__stage`: `position: relative; height: 100%; border-radius: 16px; overflow: hidden; background: var(--surface); isolation: isolate;`
- `.sst__frame`: `position: absolute; inset: 0; margin: 0; visibility: hidden; opacity: 0; will-change: transform, opacity;` `.is-active { visibility: visible; opacity: 1; z-index: 1 }`.
- `.sst__art`: `position: absolute; inset: 0;` background = `radial-gradient(55% 45% at var(--x) var(--y), var(--c1) 0%, transparent 65%), linear-gradient(180deg, var(--c2) 0%, var(--c3) var(--h), color-mix(in srgb, var(--c3) 70%, #000) 100%)`. `::before` disc: `left: var(--x); top: var(--y); width: 18%; aspect-ratio: 1; translate(-50%,-50%); border-radius: 50%; background: radial-gradient(circle at 38% 36%, #fff 0%, var(--c1) 55%, transparent 72%)`. `::after` grain: inline SVG `feTurbulence` data-URI, `opacity: .45; mix-blend-mode: overlay`. `.sst__frame::before` horizon: `top: var(--h); height: 1px; background: color-mix(in srgb, var(--c1) 40%, transparent)`.
- `.sst__num`: `position: absolute; left: clamp(1rem, 2.5vw, 2rem); bottom: clamp(.5rem, 1.5vw, 1rem); font-family: var(--font-display); font-weight: 300; font-size: clamp(5rem, 13vw, 13rem); line-height: .8; letter-spacing: -0.06em; color: #fff; mix-blend-mode: overlay; opacity: .9;` `.sst__cap`: mono 11px uppercase, bottom-right, `color: #fff; opacity: .75`.
- `.sst__dots`: `position: absolute; left: clamp(2rem, 5vw, 4rem); top: 50%; transform: translateY(-50%); display: grid; gap: 10px; z-index: 2;` `li { width: 6px; height: 6px; border-radius: 3px; background: rgba(255,255,255,.35); transition: height .45s cubic-bezier(.2,.7,.1,1), background .3s }` `li.is-active { height: 26px; background: var(--accent) }`. Accent colour (dots, eyebrows): `--accent: #d4ff3a`.
- `.sst__chapter`: `min-height: 100vh; display: grid; align-content: center; padding: clamp(2rem, 6vw, 5rem) clamp(1.25rem, 6vw, 6rem) clamp(2rem, 6vw, 5rem) clamp(1.25rem, 4vw, 4rem);` `.sst__eyebrow` mono 11px uppercase `color: var(--accent)`; `.sst__h` display serif 300 `font-size: clamp(2.25rem, 4.5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95; max-width: 12ch`; `.sst__p` muted, `max-width: 42ch; line-height: 1.55`.
- `.sst__inline { display: none }` on desktop.
**Animation spec (exact values):**
1. Intro: `gsap.from('.sst-intro > *', { y: 30, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.1, delay: 0.1 })`.
2. Inside `gsap.matchMedia().add('(min-width: 768px)', …)`: `gsap.set(frames, { autoAlpha: (i) => i === 0 ? 1 : 0 })`, then one sensor per chapter:
```js
ScrollTrigger.create({ trigger: chapter, start: 'top center', end: 'bottom center', markers: false,
onToggle: (self) => { if (self.isActive) activate(i); } });
```
3. `activate(i)` (ignored if `i === current`): `dir = i > current ? 1 : -1`; toggle `.is-active` on dots and frames; `gsap.set(next, { zIndex: 2 }); gsap.set(prev, { zIndex: 1 });` then a timeline:
- `tl.to(prev, { autoAlpha: 0, yPercent: -8 * 0.75 * dir, scale: 1.02, duration: 0.7, ease: 'power3.inOut', overwrite: 'auto' }, 0)` — outgoing frame (drifts three quarters of the incoming travel).
- `tl.fromTo(next, { autoAlpha: 0, yPercent: 8 * dir, scale: 1.06 }, { autoAlpha: 1, yPercent: 0, scale: 1, duration: 0.9, ease: 'power3.out', overwrite: 'auto' }, 0.1)` — incoming frame.
- `tl.fromTo(next.querySelector('.sst__num'), { yPercent: 30 * dir, opacity: 0 }, { yPercent: 0, opacity: 0.9, duration: 0.8, ease: 'power3.out' }, 0.25)` — chapter number.
- `tl.fromTo(next.querySelector('.sst__cap'), { opacity: 0 }, { opacity: 0.75, duration: 0.5, ease: 'power2.out' }, 0.4)` — caption.
4. Chapter copy reveal (all breakpoints): `gsap.from(chapter.querySelectorAll('.sst__eyebrow, .sst__h, .sst__p'), { y: 30, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08, scrollTrigger: { trigger: chapter, start: 'top 75%', toggleActions: 'play none none reverse' } })`.
5. `document.fonts.ready.then(() => ScrollTrigger.refresh())`.
**Interaction:** None beyond scrolling; the visual follows the chapter whose box straddles the viewport sensor line (`center`), in both directions. The dot column is decorative (`aria-hidden`).
**Responsive behavior:** Under 768px (`@media (max-width: 767px)` + the matchMedia guard): `.sst { grid-template-columns: 1fr }`, `.sst__visual { display: none }` (no sensors are created), `.sst__chapter { min-height: 0; padding: 3rem 1.25rem }`, and `.sst__inline { display: block; aspect-ratio: 4/3; border-radius: 12px; overflow: hidden; margin-bottom: 1.75rem }` with its number at `5rem`. The chapter copy reveal still runs. All type is `clamp()`-based so 375px is clean.
**Accessibility:** The frames are `<figure>`s with `<figcaption>` captions; numbers and art are `aria-hidden`. Chapters are `<article>`s with `<h2>` headings. With `prefers-reduced-motion: reduce`: no intro, no copy reveal, and `activate()` swaps frames instantly with `gsap.set` (the correct image still follows the chapter — that is state, not motion); dot transitions are disabled.
**Performance rules:** Sticky positioning is handled by the compositor; only `transform`/`opacity` are animated during swaps (`will-change: transform, opacity` on frames). Four lightweight ScrollTriggers with no `scrub` and no `pin` — no pin-spacer layout. `overwrite: 'auto'` keeps fast scrolls from stacking swaps. Everything lives in `gsap.context()` and `ctx.revert()` on unmount (matchMedia contexts are reverted with it).
**Customization knobs:**
- `SWAP_OUT` (0.7) / `SWAP_IN` (0.9) — swap tempo.
- Travel (`TRAVEL = 8` → incoming `yPercent ±8`, outgoing ±0.75×) and incoming scale (`ZOOM = 1.06`; outgoing 1.02) — set them to 0 / 1 for a pure crossfade.
- Sensor line (`SENSOR = 'center'`): `'top center' / 'bottom center'` — use `40%` to switch earlier, `60%` later.
- Per-frame `--c1/--c2/--c3/--x/--y/--h` for the artwork; replace `.sst__art` with an `<img>` in production.
- Dot / eyebrow colour `--accent` (#d4ff3a).
**Acceptance criteria:**
- [ ] The left frame stays fixed on screen for the entire four-chapter section and releases after it; the intro and outro scroll normally.
- [ ] Each chapter swaps the image exactly when its top crosses the viewport sensor line (`center`); scrolling back swaps in the opposite direction.
- [ ] Only one frame is visible at rest; the incoming frame is always on top during a swap.
- [ ] The active dot is the #d4ff3a pill and matches the visible chapter.
- [ ] Under 768px there is no sticky column; each chapter shows its inline image.
- [ ] Reduced motion: instant swaps, no reveals; no console errors.
---
**Reference implementation (use these exact values and class names):**
```html
<section class="sst-intro">
<p class="sst-intro__label">Layout · Sticky split</p>
<h1 class="sst-intro__title">Four chapters of one river</h1>
<p class="sst-intro__hint">Scroll ↓</p>
</section>
<section class="sst">
<div class="sst__visual">
<div class="sst__stage">
<figure class="sst__frame is-active" style="--c1:#dfe9f3;--c2:#7fa6c9;--c3:#1b2a3d;--x:68%;--y:30%;--h:62%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">01</span>
<figcaption class="sst__cap">Source · 1,840 m</figcaption>
</figure>
<figure class="sst__frame" style="--c1:#5fd1b8;--c2:#0f4a44;--c3:#06161a;--x:30%;--y:26%;--h:70%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">02</span>
<figcaption class="sst__cap">Gorge · 910 m</figcaption>
</figure>
<figure class="sst__frame" style="--c1:#f0c27a;--c2:#9a5a2b;--c3:#2a1a10;--x:60%;--y:36%;--h:58%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">03</span>
<figcaption class="sst__cap">Delta · 12 m</figcaption>
</figure>
<figure class="sst__frame" style="--c1:#9aa8ff;--c2:#1e2a6b;--c3:#070a1c;--x:50%;--y:22%;--h:66%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">04</span>
<figcaption class="sst__cap">Sea · 0 m</figcaption>
</figure>
</div>
<ol class="sst__dots" aria-hidden="true"><li class="is-active"></li><li></li><li></li><li></li></ol>
</div>
<div class="sst__chapters">
<article class="sst__chapter">
<figure class="sst__inline" style="--c1:#dfe9f3;--c2:#7fa6c9;--c3:#1b2a3d;--x:68%;--y:30%;--h:62%"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">01</span></figure>
<p class="sst__eyebrow">Chapter 01 — Source</p>
<h2 class="sst__h">It begins as weather</h2>
<p class="sst__p">Snow that fell in March lets go in June. Under the glacier's lip the meltwater is the colour of milk glass, loud, and barely a stride wide. Nothing here suggests the thing it will become.</p>
</article>
<article class="sst__chapter">
<figure class="sst__inline" style="--c1:#5fd1b8;--c2:#0f4a44;--c3:#06161a;--x:30%;--y:26%;--h:70%"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">02</span></figure>
<p class="sst__eyebrow">Chapter 02 — Gorge</p>
<h2 class="sst__h">The mountain gives way</h2>
<p class="sst__p">For eleven kilometres the river has cut a slot so narrow the sun reaches the bottom for forty minutes a day. The water turns the green of old bottles and moves faster than you can walk.</p>
</article>
<article class="sst__chapter">
<figure class="sst__inline" style="--c1:#f0c27a;--c2:#9a5a2b;--c3:#2a1a10;--x:60%;--y:36%;--h:58%"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">03</span></figure>
<p class="sst__eyebrow">Chapter 03 — Delta</p>
<h2 class="sst__h">It slows, and spreads</h2>
<p class="sst__p">Released onto the plain, the river forgets its hurry. It braids into channels, drops the silt it carried for three hundred kilometres and builds the fields that feed four towns.</p>
</article>
<article class="sst__chapter">
<figure class="sst__inline" style="--c1:#9aa8ff;--c2:#1e2a6b;--c3:#070a1c;--x:50%;--y:22%;--h:66%"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">04</span></figure>
<p class="sst__eyebrow">Chapter 04 — Sea</p>
<h2 class="sst__h">Then it is not a river</h2>
<p class="sst__p">At the mouth the fresh water rides out over the salt for almost a mile before it mixes. From the air you can see the line. Then the tide turns, and there is no line at all.</p>
</article>
</div>
</section>
<section class="sst-outro">
<p class="sst-outro__text">End of the feature — the page continues normally.</p>
</section>
```
```css
.sst-intro, .sst-outro { min-height: 100vh; display: grid; place-content: center; text-align: center; padding: 6vw; }
.sst-intro__label, .sst-intro__hint { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.sst-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92; margin: 1.5rem 0 2.5rem; max-width: 12ch; }
.sst-outro__text { color: var(--muted); max-width: 40ch; }
.sst { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
.sst__visual { position: sticky; top: 0; height: 100vh; padding: clamp(1rem, 3vw, 2.5rem); }
.sst__stage { position: relative; height: 100%; border-radius: 16px; overflow: hidden; background: var(--surface); isolation: isolate; }
.sst__frame { position: absolute; inset: 0; margin: 0; visibility: hidden; opacity: 0; will-change: transform, opacity; }
.sst__frame.is-active { visibility: visible; opacity: 1; z-index: 1; }
.sst__art {
position: absolute; inset: 0;
background:
radial-gradient(55% 45% at var(--x) var(--y), var(--c1) 0%, transparent 65%),
linear-gradient(180deg, var(--c2) 0%, var(--c3) var(--h), color-mix(in srgb, var(--c3) 70%, #000) 100%);
}
.sst__art::before { content: ''; position: absolute; left: var(--x); top: var(--y); width: 18%; aspect-ratio: 1; transform: translate(-50%, -50%); border-radius: 50%; background: radial-gradient(circle at 38% 36%, #fff 0%, var(--c1) 55%, transparent 72%); filter: blur(.5px); }
.sst__art::after {
content: ''; position: absolute; inset: 0; opacity: .45; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.sst__frame::before { content: ''; position: absolute; left: 0; right: 0; top: var(--h); height: 1px; background: color-mix(in srgb, var(--c1) 40%, transparent); }
.sst__num { position: absolute; left: clamp(1rem, 2.5vw, 2rem); bottom: clamp(.5rem, 1.5vw, 1rem); font-family: var(--font-display); font-weight: 300; font-size: clamp(5rem, 13vw, 13rem); line-height: .8; letter-spacing: -0.06em; color: #fff; mix-blend-mode: overlay; opacity: .9; }
.sst__cap { position: absolute; right: clamp(1rem, 2.5vw, 2rem); bottom: clamp(1rem, 2.5vw, 2rem); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: #fff; opacity: .75; }
.sst__dots { position: absolute; left: clamp(2rem, 5vw, 4rem); top: 50%; transform: translateY(-50%); display: grid; gap: 10px; list-style: none; z-index: 2; }
.sst__dots li { width: 6px; height: 6px; border-radius: 3px; background: rgba(255,255,255,.35); transition: height .45s cubic-bezier(.2,.7,.1,1), background .3s; }
.sst__dots li.is-active { height: 26px; background: var(--knob-accent, var(--accent)); }
.sst__chapters { display: flex; flex-direction: column; }
.sst__chapter { min-height: 100vh; display: grid; align-content: center; padding: clamp(2rem, 6vw, 5rem) clamp(1.25rem, 6vw, 6rem) clamp(2rem, 6vw, 5rem) clamp(1.25rem, 4vw, 4rem); }
.sst__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--knob-accent, var(--accent)); margin-bottom: 1.25rem; }
.sst__h { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.25rem, 4.5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95; max-width: 12ch; }
.sst__p { margin-top: 1.5rem; max-width: 42ch; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.55; }
.sst__inline { display: none; }
@media (max-width: 767px) {
.sst { grid-template-columns: 1fr; }
.sst__visual { display: none; }
.sst__chapter { min-height: 0; padding: 3rem 1.25rem; }
.sst__inline { display: block; position: relative; margin: 0 0 1.75rem; aspect-ratio: 4 / 3; border-radius: 12px; overflow: hidden; isolation: isolate; }
.sst__inline .sst__num { font-size: 5rem; left: 1rem; bottom: .5rem; }
}
@media (prefers-reduced-motion: reduce) {
.sst__dots li { transition: none; }
}
```
```js
// Sticky Split-Screen Storytelling — sticky visual column, ScrollTrigger as a chapter sensor.
gsap.registerPlugin(ScrollTrigger);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { swapOut: 0.7, swapIn: 0.9, travel: 8, zoom: 1.06, sensor: 'center', accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.sst');
const frames = gsap.utils.toArray('.sst__frame');
const chapters = gsap.utils.toArray('.sst__chapter');
const dots = gsap.utils.toArray('.sst__dots li');
let ctx;
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
const SWAP_OUT = K('swapOut'); // outgoing frame
const SWAP_IN = K('swapIn'); // incoming frame
const TRAVEL = K('travel'); // incoming yPercent; the outgoing frame drifts 3/4 of it
const ZOOM = K('zoom'); // incoming start scale
const SENSOR = K('sensor'); // viewport line that selects the chapter
// The active chapter lives in the classes, so a rebuild picks up where the reader is.
let current = Math.max(0, frames.findIndex((f) => f.classList.contains('is-active')));
ctx = gsap.context(() => {
if (!reduced) gsap.from('.sst-intro > *', { y: 30, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.1, delay: 0.1 });
function activate(i) {
if (i === current) return;
const dir = i > current ? 1 : -1; // 1 = reading forward
const prev = frames[current];
const next = frames[i];
current = i;
dots.forEach((d, k) => d.classList.toggle('is-active', k === i));
frames.forEach((f, k) => f.classList.toggle('is-active', k === i));
gsap.set(next, { zIndex: 2 });
gsap.set(prev, { zIndex: 1 });
if (reduced) {
gsap.set(prev, { autoAlpha: 0 });
gsap.set(next, { autoAlpha: 1, yPercent: 0, scale: 1 });
return;
}
ctx.add(() => gsap.timeline() // recorded in the context so a Playground rebuild reverts an in-flight swap
.to(prev, { autoAlpha: 0, yPercent: -TRAVEL * 0.75 * dir, scale: 1.02, duration: SWAP_OUT, ease: 'power3.inOut', overwrite: 'auto' }, 0)
.fromTo(next, { autoAlpha: 0, yPercent: TRAVEL * dir, scale: ZOOM }, { autoAlpha: 1, yPercent: 0, scale: 1, duration: SWAP_IN, ease: 'power3.out', overwrite: 'auto' }, 0.1)
.fromTo(next.querySelector('.sst__num'), { yPercent: 30 * dir, opacity: 0 }, { yPercent: 0, opacity: 0.9, duration: 0.8, ease: 'power3.out' }, 0.25)
.fromTo(next.querySelector('.sst__cap'), { opacity: 0 }, { opacity: 0.75, duration: 0.5, ease: 'power2.out' }, 0.4));
}
const mm = gsap.matchMedia();
// Desktop: sticky visual + chapter sensors
mm.add('(min-width: 768px)', () => {
gsap.set(frames, { autoAlpha: (i) => (i === current ? 1 : 0), yPercent: 0, scale: 1 });
chapters.forEach((chapter, i) => {
ScrollTrigger.create({
trigger: chapter, start: `top ${SENSOR}`, end: `bottom ${SENSOR}`, markers: false,
onToggle: (self) => { if (self.isActive) activate(i); },
});
});
});
// Chapter copy reveal (all sizes)
if (!reduced) {
chapters.forEach((chapter) => {
gsap.from(chapter.querySelectorAll('.sst__eyebrow, .sst__h, .sst__p'), {
y: 30, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08,
scrollTrigger: { trigger: chapter, start: 'top 75%', toggleActions: 'play none none reverse' },
});
});
}
document.fonts.ready.then(() => ScrollTrigger.refresh());
});
}
build();
// Playground: rebuild (sensors are re-created at the new line) with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (Framer & Webflow notes): Sticky Split-Screen Storytelling
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withStickySplitScreenStorytelling(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis. ScrollTrigger works on the page scroller — call `ScrollTrigger.refresh()` after images load.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `sst-intro`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the pinning/scrubbing in code — IX2 cannot do pin spacing or `containerAnimation`.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### EFFECT: Sticky Split-Screen Storytelling
**What it does (visual):** After a full-height intro ("Four chapters of one river"), the page becomes two columns. The left half is a rounded 16px frame filling the viewport height that *stays in place* while the page scrolls; it shows a procedural landscape — a soft radial glow and a luminous disc over a two-tone vertical gradient with a thin horizon line and film grain — plus a huge light-serif chapter number ("01") in the bottom-left and a tiny mono caption ("Source · 1,840 m") bottom-right. A column of four small dots at the left edge marks progress; the active dot is a 26px pill in the accent colour (#d4ff3a). The right half holds four chapters, each a full viewport tall, with a mono eyebrow in the same accent ("Chapter 02 — Gorge"), a large serif headline and a short muted paragraph that rise 30px into view as the chapter approaches. The moment a chapter's top passes the sensor line of the viewport (`center`), the left image swaps: the old frame fades out over 0.7s and drifts up three quarters of 8% while scaling to 102%, the new frame fades in over 0.9s from 8% below while settling from 1.06× scale, its chapter number rising in a beat later. Scrolling back up plays the same swap downward. The image changes four times; the frame never moves. After the fourth chapter the sticky column releases and an outro section scrolls in normally. Under 768px the sticky column disappears and each chapter shows its own 4:3 inline image above the text.
**Tech stack:**
- GSAP 3.13+ and ScrollTrigger.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);`
- The sticky behaviour is pure CSS (`position: sticky`); ScrollTrigger is only a sensor. Works with Lenis if present.
**HTML structure:**
```html
<section class="sst-intro"><p class="sst-intro__label">Layout · Sticky split</p><h1 class="sst-intro__title">Four chapters of one river</h1><p class="sst-intro__hint">Scroll ↓</p></section>
<section class="sst">
<div class="sst__visual">
<div class="sst__stage">
<figure class="sst__frame is-active" style="--c1:#dfe9f3;--c2:#7fa6c9;--c3:#1b2a3d;--x:68%;--y:30%;--h:62%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">01</span>
<figcaption class="sst__cap">Source · 1,840 m</figcaption>
</figure>
<!-- 3 more .sst__frame (02 Gorge, 03 Delta, 04 Sea) with their own --c1/--c2/--c3/--x/--y/--h -->
</div>
<ol class="sst__dots" aria-hidden="true"><li class="is-active"></li><li></li><li></li><li></li></ol>
</div>
<div class="sst__chapters">
<article class="sst__chapter">
<figure class="sst__inline" style="/* same vars as frame 01 */"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">01</span></figure>
<p class="sst__eyebrow">Chapter 01 — Source</p>
<h2 class="sst__h">It begins as weather</h2>
<p class="sst__p">Snow that fell in March lets go in June…</p>
</article>
<!-- 3 more .sst__chapter -->
</div>
</section>
<section class="sst-outro"><p class="sst-outro__text">End of the feature.</p></section>
```
Per-frame variables: `--c1` glow/disc colour, `--c2` sky, `--c3` ground, `--x/--y` disc position, `--h` horizon height.
**CSS requirements:**
- `.sst-intro, .sst-outro`: `min-height: 100vh; display: grid; place-content: center; text-align: center; padding: 6vw;` title: display serif 300, `font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92`.
- `.sst`: `display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); align-items: start;` — `align-items: start` is REQUIRED or the sticky child is stretched and never sticks. No `overflow: hidden` on any ancestor.
- `.sst__visual`: `position: sticky; top: 0; height: 100vh; padding: clamp(1rem, 3vw, 2.5rem);`
- `.sst__stage`: `position: relative; height: 100%; border-radius: 16px; overflow: hidden; background: var(--surface); isolation: isolate;`
- `.sst__frame`: `position: absolute; inset: 0; margin: 0; visibility: hidden; opacity: 0; will-change: transform, opacity;` `.is-active { visibility: visible; opacity: 1; z-index: 1 }`.
- `.sst__art`: `position: absolute; inset: 0;` background = `radial-gradient(55% 45% at var(--x) var(--y), var(--c1) 0%, transparent 65%), linear-gradient(180deg, var(--c2) 0%, var(--c3) var(--h), color-mix(in srgb, var(--c3) 70%, #000) 100%)`. `::before` disc: `left: var(--x); top: var(--y); width: 18%; aspect-ratio: 1; translate(-50%,-50%); border-radius: 50%; background: radial-gradient(circle at 38% 36%, #fff 0%, var(--c1) 55%, transparent 72%)`. `::after` grain: inline SVG `feTurbulence` data-URI, `opacity: .45; mix-blend-mode: overlay`. `.sst__frame::before` horizon: `top: var(--h); height: 1px; background: color-mix(in srgb, var(--c1) 40%, transparent)`.
- `.sst__num`: `position: absolute; left: clamp(1rem, 2.5vw, 2rem); bottom: clamp(.5rem, 1.5vw, 1rem); font-family: var(--font-display); font-weight: 300; font-size: clamp(5rem, 13vw, 13rem); line-height: .8; letter-spacing: -0.06em; color: #fff; mix-blend-mode: overlay; opacity: .9;` `.sst__cap`: mono 11px uppercase, bottom-right, `color: #fff; opacity: .75`.
- `.sst__dots`: `position: absolute; left: clamp(2rem, 5vw, 4rem); top: 50%; transform: translateY(-50%); display: grid; gap: 10px; z-index: 2;` `li { width: 6px; height: 6px; border-radius: 3px; background: rgba(255,255,255,.35); transition: height .45s cubic-bezier(.2,.7,.1,1), background .3s }` `li.is-active { height: 26px; background: var(--accent) }`. Accent colour (dots, eyebrows): `--accent: #d4ff3a`.
- `.sst__chapter`: `min-height: 100vh; display: grid; align-content: center; padding: clamp(2rem, 6vw, 5rem) clamp(1.25rem, 6vw, 6rem) clamp(2rem, 6vw, 5rem) clamp(1.25rem, 4vw, 4rem);` `.sst__eyebrow` mono 11px uppercase `color: var(--accent)`; `.sst__h` display serif 300 `font-size: clamp(2.25rem, 4.5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95; max-width: 12ch`; `.sst__p` muted, `max-width: 42ch; line-height: 1.55`.
- `.sst__inline { display: none }` on desktop.
**Animation spec (exact values):**
1. Intro: `gsap.from('.sst-intro > *', { y: 30, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.1, delay: 0.1 })`.
2. Inside `gsap.matchMedia().add('(min-width: 768px)', …)`: `gsap.set(frames, { autoAlpha: (i) => i === 0 ? 1 : 0 })`, then one sensor per chapter:
```js
ScrollTrigger.create({ trigger: chapter, start: 'top center', end: 'bottom center', markers: false,
onToggle: (self) => { if (self.isActive) activate(i); } });
```
3. `activate(i)` (ignored if `i === current`): `dir = i > current ? 1 : -1`; toggle `.is-active` on dots and frames; `gsap.set(next, { zIndex: 2 }); gsap.set(prev, { zIndex: 1 });` then a timeline:
- `tl.to(prev, { autoAlpha: 0, yPercent: -8 * 0.75 * dir, scale: 1.02, duration: 0.7, ease: 'power3.inOut', overwrite: 'auto' }, 0)` — outgoing frame (drifts three quarters of the incoming travel).
- `tl.fromTo(next, { autoAlpha: 0, yPercent: 8 * dir, scale: 1.06 }, { autoAlpha: 1, yPercent: 0, scale: 1, duration: 0.9, ease: 'power3.out', overwrite: 'auto' }, 0.1)` — incoming frame.
- `tl.fromTo(next.querySelector('.sst__num'), { yPercent: 30 * dir, opacity: 0 }, { yPercent: 0, opacity: 0.9, duration: 0.8, ease: 'power3.out' }, 0.25)` — chapter number.
- `tl.fromTo(next.querySelector('.sst__cap'), { opacity: 0 }, { opacity: 0.75, duration: 0.5, ease: 'power2.out' }, 0.4)` — caption.
4. Chapter copy reveal (all breakpoints): `gsap.from(chapter.querySelectorAll('.sst__eyebrow, .sst__h, .sst__p'), { y: 30, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08, scrollTrigger: { trigger: chapter, start: 'top 75%', toggleActions: 'play none none reverse' } })`.
5. `document.fonts.ready.then(() => ScrollTrigger.refresh())`.
**Interaction:** None beyond scrolling; the visual follows the chapter whose box straddles the viewport sensor line (`center`), in both directions. The dot column is decorative (`aria-hidden`).
**Responsive behavior:** Under 768px (`@media (max-width: 767px)` + the matchMedia guard): `.sst { grid-template-columns: 1fr }`, `.sst__visual { display: none }` (no sensors are created), `.sst__chapter { min-height: 0; padding: 3rem 1.25rem }`, and `.sst__inline { display: block; aspect-ratio: 4/3; border-radius: 12px; overflow: hidden; margin-bottom: 1.75rem }` with its number at `5rem`. The chapter copy reveal still runs. All type is `clamp()`-based so 375px is clean.
**Accessibility:** The frames are `<figure>`s with `<figcaption>` captions; numbers and art are `aria-hidden`. Chapters are `<article>`s with `<h2>` headings. With `prefers-reduced-motion: reduce`: no intro, no copy reveal, and `activate()` swaps frames instantly with `gsap.set` (the correct image still follows the chapter — that is state, not motion); dot transitions are disabled.
**Performance rules:** Sticky positioning is handled by the compositor; only `transform`/`opacity` are animated during swaps (`will-change: transform, opacity` on frames). Four lightweight ScrollTriggers with no `scrub` and no `pin` — no pin-spacer layout. `overwrite: 'auto'` keeps fast scrolls from stacking swaps. Everything lives in `gsap.context()` and `ctx.revert()` on unmount (matchMedia contexts are reverted with it).
**Customization knobs:**
- `SWAP_OUT` (0.7) / `SWAP_IN` (0.9) — swap tempo.
- Travel (`TRAVEL = 8` → incoming `yPercent ±8`, outgoing ±0.75×) and incoming scale (`ZOOM = 1.06`; outgoing 1.02) — set them to 0 / 1 for a pure crossfade.
- Sensor line (`SENSOR = 'center'`): `'top center' / 'bottom center'` — use `40%` to switch earlier, `60%` later.
- Per-frame `--c1/--c2/--c3/--x/--y/--h` for the artwork; replace `.sst__art` with an `<img>` in production.
- Dot / eyebrow colour `--accent` (#d4ff3a).
**Acceptance criteria:**
- [ ] The left frame stays fixed on screen for the entire four-chapter section and releases after it; the intro and outro scroll normally.
- [ ] Each chapter swaps the image exactly when its top crosses the viewport sensor line (`center`); scrolling back swaps in the opposite direction.
- [ ] Only one frame is visible at rest; the incoming frame is always on top during a swap.
- [ ] The active dot is the #d4ff3a pill and matches the visible chapter.
- [ ] Under 768px there is no sticky column; each chapter shows its inline image.
- [ ] Reduced motion: instant swaps, no reveals; no console errors.
Source
Full source code
<section class="sst-intro">
<p class="sst-intro__label">Layout · Sticky split</p>
<h1 class="sst-intro__title">Four chapters of one river</h1>
<p class="sst-intro__hint">Scroll ↓</p>
</section>
<section class="sst">
<div class="sst__visual">
<div class="sst__stage">
<figure class="sst__frame is-active" style="--c1:#dfe9f3;--c2:#7fa6c9;--c3:#1b2a3d;--x:68%;--y:30%;--h:62%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">01</span>
<figcaption class="sst__cap">Source · 1,840 m</figcaption>
</figure>
<figure class="sst__frame" style="--c1:#5fd1b8;--c2:#0f4a44;--c3:#06161a;--x:30%;--y:26%;--h:70%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">02</span>
<figcaption class="sst__cap">Gorge · 910 m</figcaption>
</figure>
<figure class="sst__frame" style="--c1:#f0c27a;--c2:#9a5a2b;--c3:#2a1a10;--x:60%;--y:36%;--h:58%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">03</span>
<figcaption class="sst__cap">Delta · 12 m</figcaption>
</figure>
<figure class="sst__frame" style="--c1:#9aa8ff;--c2:#1e2a6b;--c3:#070a1c;--x:50%;--y:22%;--h:66%">
<div class="sst__art" aria-hidden="true"></div>
<span class="sst__num" aria-hidden="true">04</span>
<figcaption class="sst__cap">Sea · 0 m</figcaption>
</figure>
</div>
<ol class="sst__dots" aria-hidden="true"><li class="is-active"></li><li></li><li></li><li></li></ol>
</div>
<div class="sst__chapters">
<article class="sst__chapter">
<figure class="sst__inline" style="--c1:#dfe9f3;--c2:#7fa6c9;--c3:#1b2a3d;--x:68%;--y:30%;--h:62%"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">01</span></figure>
<p class="sst__eyebrow">Chapter 01 — Source</p>
<h2 class="sst__h">It begins as weather</h2>
<p class="sst__p">Snow that fell in March lets go in June. Under the glacier's lip the meltwater is the colour of milk glass, loud, and barely a stride wide. Nothing here suggests the thing it will become.</p>
</article>
<article class="sst__chapter">
<figure class="sst__inline" style="--c1:#5fd1b8;--c2:#0f4a44;--c3:#06161a;--x:30%;--y:26%;--h:70%"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">02</span></figure>
<p class="sst__eyebrow">Chapter 02 — Gorge</p>
<h2 class="sst__h">The mountain gives way</h2>
<p class="sst__p">For eleven kilometres the river has cut a slot so narrow the sun reaches the bottom for forty minutes a day. The water turns the green of old bottles and moves faster than you can walk.</p>
</article>
<article class="sst__chapter">
<figure class="sst__inline" style="--c1:#f0c27a;--c2:#9a5a2b;--c3:#2a1a10;--x:60%;--y:36%;--h:58%"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">03</span></figure>
<p class="sst__eyebrow">Chapter 03 — Delta</p>
<h2 class="sst__h">It slows, and spreads</h2>
<p class="sst__p">Released onto the plain, the river forgets its hurry. It braids into channels, drops the silt it carried for three hundred kilometres and builds the fields that feed four towns.</p>
</article>
<article class="sst__chapter">
<figure class="sst__inline" style="--c1:#9aa8ff;--c2:#1e2a6b;--c3:#070a1c;--x:50%;--y:22%;--h:66%"><div class="sst__art" aria-hidden="true"></div><span class="sst__num" aria-hidden="true">04</span></figure>
<p class="sst__eyebrow">Chapter 04 — Sea</p>
<h2 class="sst__h">Then it is not a river</h2>
<p class="sst__p">At the mouth the fresh water rides out over the salt for almost a mile before it mixes. From the air you can see the line. Then the tide turns, and there is no line at all.</p>
</article>
</div>
</section>
<section class="sst-outro">
<p class="sst-outro__text">End of the feature — the page continues normally.</p>
</section>
.sst-intro, .sst-outro { min-height: 100vh; display: grid; place-content: center; text-align: center; padding: 6vw; }
.sst-intro__label, .sst-intro__hint { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.sst-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92; margin: 1.5rem 0 2.5rem; max-width: 12ch; }
.sst-outro__text { color: var(--muted); max-width: 40ch; }
.sst { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
.sst__visual { position: sticky; top: 0; height: 100vh; padding: clamp(1rem, 3vw, 2.5rem); }
.sst__stage { position: relative; height: 100%; border-radius: 16px; overflow: hidden; background: var(--surface); isolation: isolate; }
.sst__frame { position: absolute; inset: 0; margin: 0; visibility: hidden; opacity: 0; will-change: transform, opacity; }
.sst__frame.is-active { visibility: visible; opacity: 1; z-index: 1; }
.sst__art {
position: absolute; inset: 0;
background:
radial-gradient(55% 45% at var(--x) var(--y), var(--c1) 0%, transparent 65%),
linear-gradient(180deg, var(--c2) 0%, var(--c3) var(--h), color-mix(in srgb, var(--c3) 70%, #000) 100%);
}
.sst__art::before { content: ''; position: absolute; left: var(--x); top: var(--y); width: 18%; aspect-ratio: 1; transform: translate(-50%, -50%); border-radius: 50%; background: radial-gradient(circle at 38% 36%, #fff 0%, var(--c1) 55%, transparent 72%); filter: blur(.5px); }
.sst__art::after {
content: ''; position: absolute; inset: 0; opacity: .45; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.sst__frame::before { content: ''; position: absolute; left: 0; right: 0; top: var(--h); height: 1px; background: color-mix(in srgb, var(--c1) 40%, transparent); }
.sst__num { position: absolute; left: clamp(1rem, 2.5vw, 2rem); bottom: clamp(.5rem, 1.5vw, 1rem); font-family: var(--font-display); font-weight: 300; font-size: clamp(5rem, 13vw, 13rem); line-height: .8; letter-spacing: -0.06em; color: #fff; mix-blend-mode: overlay; opacity: .9; }
.sst__cap { position: absolute; right: clamp(1rem, 2.5vw, 2rem); bottom: clamp(1rem, 2.5vw, 2rem); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: #fff; opacity: .75; }
.sst__dots { position: absolute; left: clamp(2rem, 5vw, 4rem); top: 50%; transform: translateY(-50%); display: grid; gap: 10px; list-style: none; z-index: 2; }
.sst__dots li { width: 6px; height: 6px; border-radius: 3px; background: rgba(255,255,255,.35); transition: height .45s cubic-bezier(.2,.7,.1,1), background .3s; }
.sst__dots li.is-active { height: 26px; background: var(--knob-accent, var(--accent)); }
.sst__chapters { display: flex; flex-direction: column; }
.sst__chapter { min-height: 100vh; display: grid; align-content: center; padding: clamp(2rem, 6vw, 5rem) clamp(1.25rem, 6vw, 6rem) clamp(2rem, 6vw, 5rem) clamp(1.25rem, 4vw, 4rem); }
.sst__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--knob-accent, var(--accent)); margin-bottom: 1.25rem; }
.sst__h { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.25rem, 4.5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95; max-width: 12ch; }
.sst__p { margin-top: 1.5rem; max-width: 42ch; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.55; }
.sst__inline { display: none; }
@media (max-width: 767px) {
.sst { grid-template-columns: 1fr; }
.sst__visual { display: none; }
.sst__chapter { min-height: 0; padding: 3rem 1.25rem; }
.sst__inline { display: block; position: relative; margin: 0 0 1.75rem; aspect-ratio: 4 / 3; border-radius: 12px; overflow: hidden; isolation: isolate; }
.sst__inline .sst__num { font-size: 5rem; left: 1rem; bottom: .5rem; }
}
@media (prefers-reduced-motion: reduce) {
.sst__dots li { transition: none; }
}
// Sticky Split-Screen Storytelling — sticky visual column, ScrollTrigger as a chapter sensor.
gsap.registerPlugin(ScrollTrigger);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { swapOut: 0.7, swapIn: 0.9, travel: 8, zoom: 1.06, sensor: 'center', accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.sst');
const frames = gsap.utils.toArray('.sst__frame');
const chapters = gsap.utils.toArray('.sst__chapter');
const dots = gsap.utils.toArray('.sst__dots li');
let ctx;
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
const SWAP_OUT = K('swapOut'); // outgoing frame
const SWAP_IN = K('swapIn'); // incoming frame
const TRAVEL = K('travel'); // incoming yPercent; the outgoing frame drifts 3/4 of it
const ZOOM = K('zoom'); // incoming start scale
const SENSOR = K('sensor'); // viewport line that selects the chapter
// The active chapter lives in the classes, so a rebuild picks up where the reader is.
let current = Math.max(0, frames.findIndex((f) => f.classList.contains('is-active')));
ctx = gsap.context(() => {
if (!reduced) gsap.from('.sst-intro > *', { y: 30, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.1, delay: 0.1 });
function activate(i) {
if (i === current) return;
const dir = i > current ? 1 : -1; // 1 = reading forward
const prev = frames[current];
const next = frames[i];
current = i;
dots.forEach((d, k) => d.classList.toggle('is-active', k === i));
frames.forEach((f, k) => f.classList.toggle('is-active', k === i));
gsap.set(next, { zIndex: 2 });
gsap.set(prev, { zIndex: 1 });
if (reduced) {
gsap.set(prev, { autoAlpha: 0 });
gsap.set(next, { autoAlpha: 1, yPercent: 0, scale: 1 });
return;
}
ctx.add(() => gsap.timeline() // recorded in the context so a Playground rebuild reverts an in-flight swap
.to(prev, { autoAlpha: 0, yPercent: -TRAVEL * 0.75 * dir, scale: 1.02, duration: SWAP_OUT, ease: 'power3.inOut', overwrite: 'auto' }, 0)
.fromTo(next, { autoAlpha: 0, yPercent: TRAVEL * dir, scale: ZOOM }, { autoAlpha: 1, yPercent: 0, scale: 1, duration: SWAP_IN, ease: 'power3.out', overwrite: 'auto' }, 0.1)
.fromTo(next.querySelector('.sst__num'), { yPercent: 30 * dir, opacity: 0 }, { yPercent: 0, opacity: 0.9, duration: 0.8, ease: 'power3.out' }, 0.25)
.fromTo(next.querySelector('.sst__cap'), { opacity: 0 }, { opacity: 0.75, duration: 0.5, ease: 'power2.out' }, 0.4));
}
const mm = gsap.matchMedia();
// Desktop: sticky visual + chapter sensors
mm.add('(min-width: 768px)', () => {
gsap.set(frames, { autoAlpha: (i) => (i === current ? 1 : 0), yPercent: 0, scale: 1 });
chapters.forEach((chapter, i) => {
ScrollTrigger.create({
trigger: chapter, start: `top ${SENSOR}`, end: `bottom ${SENSOR}`, markers: false,
onToggle: (self) => { if (self.isActive) activate(i); },
});
});
});
// Chapter copy reveal (all sizes)
if (!reduced) {
chapters.forEach((chapter) => {
gsap.from(chapter.querySelectorAll('.sst__eyebrow, .sst__h, .sst__p'), {
y: 30, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08,
scrollTrigger: { trigger: chapter, start: 'top 75%', toggleActions: 'play none none reverse' },
});
});
}
document.fonts.ready.then(() => ScrollTrigger.refresh());
});
}
build();
// Playground: rebuild (sensors are re-created at the new line) with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());