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.

Loading

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.

prompt.md · Sticky Split-Screen Storytelling
### 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>