Before / After Drag Slider
A 16:10 comparison frame with a warm graded 'after' and a flat, desaturated 'before' derived from it on canvas; a lime Draggable handle clips the top layer, with keyboard, click-to-jump and an intro sweep to 60% and back.
- Layout
- gsap
- canvas2d
- intermediate
- light perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1496 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Before / After Drag Slider
**What it does (visual):** A dark page with a heading ("The grade, before and after") and a short lead, then a wide 16:10 rounded frame. Inside is a dusk seascape — a plum-to-apricot sky, a low sun with a warm glow, three layered mountain ridges, a dark sea carrying a streaked sun path, black foreground rocks, a vignette and fine grain. The frame is split by a 2px vertical line in the accent colour (#d4ff3a) with a 44px circle of the same colour in the middle holding two small chevrons. Left of the line the same picture is flat and desaturated, with a cool blue-grey haze when `HAZE` is true (the ungraded "before"); right of the line it is the warm graded "after". Two frosted pill labels sit in the top corners: "Before" (left) and "After" (right). On load the heading fades up, the frame rises 30px, and 0.3s later the handle glides from the resting split (0.5 of the width) to 0.6 and back over 2 × 0.8s, revealing more of one side then settling — a silent hint that it can be dragged. Dragging the circle moves the split in real time; the "Before" label fades out as the handle nears the left edge and "After" fades as it nears the right. Clicking anywhere in the frame slides the handle there in 0.6s. The knob scales to 108% on hover and 94% while dragging. With the handle focused, arrow keys nudge it 2% per press.
**Tech stack:**
- GSAP 3.13+ with the Draggable plugin (free since 3.13).
- 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/Draggable.min.js"></script>`
- npm: `import gsap from "gsap"; import { Draggable } from "gsap/Draggable"; gsap.registerPlugin(Draggable);`
- Canvas 2D for the procedural scene and for deriving the "before" (`ctx.filter`, with a per-pixel fallback). In production use two real `<img>`s with identical dimensions instead of the canvases.
**HTML structure:**
```html
<section class="ba">
<header class="ba__head">
<p class="ba__eyebrow">Layout · Draggable + clip-path</p>
<h1 class="ba__title">The grade, <em>before and after</em></h1>
<p class="ba__lead">Drag the handle, click anywhere in the frame, or use the arrow keys.</p>
</header>
<div class="ba__frame">
<canvas class="ba__canvas ba__canvas--after" width="1600" height="1000" aria-hidden="true"></canvas>
<div class="ba__top"><canvas class="ba__canvas ba__canvas--before" width="1600" height="1000" aria-hidden="true"></canvas></div>
<span class="ba__label ba__label--before" aria-hidden="true">Before</span>
<span class="ba__label ba__label--after" aria-hidden="true">After</span>
<div class="ba__handle" role="slider" tabindex="0" aria-label="Comparison position" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" aria-valuetext="50% before, 50% after" aria-orientation="horizontal">
<span class="ba__knob" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 7l-5 5 5 5M15 7l5 5-5 5"/></svg></span>
</div>
</div>
<p class="ba__caption"><span class="ba__caption-num">01</span> Kalk Bay, 06:41 — procedural frame</p>
</section>
```
The "before" layer is on top and is clipped; the "after" layer underneath is always fully painted.
**CSS requirements:**
- `.ba`: `min-height: 100vh; max-width: 1400px; margin-inline: auto; padding: clamp(2rem, 6vw, 5rem) clamp(1rem, 5vw, 4rem); display: grid; align-content: center; gap: clamp(1.5rem, 3vw, 2.5rem);`
- `.ba__head`: two-column grid (`minmax(0,1fr) minmax(0,1fr)`, `align-items: end`); eyebrow spans both columns; `.ba__title` display serif 300, `font-size: clamp(2.25rem, 5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95` with an italic muted `<em>`; `.ba__lead` muted, `max-width: 46ch; justify-self: end`.
- `.ba__frame`: `position: relative; width: min(100%, calc((100vh - 16rem) * 1.6)); margin-inline: auto; aspect-ratio: 16 / 10; border-radius: 14px; overflow: hidden; background: var(--surface); border: 1px solid var(--line); user-select: none; touch-action: pan-y; cursor: ew-resize;`
- `.ba__canvas`: `position: absolute; inset: 0; width: 100%; height: 100%;`
- `.ba__top`: `position: absolute; inset: 0; clip-path: inset(0 50% 0 0); will-change: clip-path;` — JS writes `inset(0 ${(1 - p) * 100}% 0 0)`.
- `.ba__label`: `position: absolute; top: clamp(.75rem, 2vw, 1.25rem); font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .14em; padding: .45em .8em; border-radius: 999px; color: #fff; background: rgba(0,0,0,.42); backdrop-filter: blur(6px); pointer-events: none;` `--before { left: … }`, `--after { right: … }`.
- `.ba__handle`: `position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: var(--accent); cursor: ew-resize; touch-action: none; will-change: transform;` `::before { content: ''; position: absolute; inset: 0 -22px; }` widens the hit area to 46px.
- `.ba__knob`: `position: absolute; left: 50%; top: 50%; width: 44px; height: 44px; transform: translate(-50%, -50%); display: grid; place-items: center; border-radius: 50%; background: var(--accent); color: #0a0a0a; box-shadow: 0 6px 24px rgba(0,0,0,.35); transition: transform .3s cubic-bezier(.2,.7,.1,1);` hover/focus-visible → `scale(1.08)`, `.is-dragging` → `scale(.94)`. Focus ring: `box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent)` on the knob.
- `.ba__caption`: mono 11px muted; the index is `var(--accent)`.
- Accent colour (handle line, knob, caption index, focus ring): `--accent: #d4ff3a`.
**Animation spec (exact values):**
1. Paint the scene on the 1600×1000 "after" canvas (seeded `mulberry32(7)` RNG so it is deterministic): sky `linearGradient(0→H·0.70)` stops `#171231 @0, #5d2e4c @.42, #d9704a @.78, #f6c58d @1`; sun glow `radialGradient` at `(0.62W, 0.585H)` radius `0.38W` from `rgba(255,220,160,.95)` → `rgba(255,170,100,.4) @.22` → transparent; sun disc radius `0.052H` `#ffe6b8`; three ridges as filled polylines `y = H·(base − amp·wave)` with `wave = .55·sin(πft+i) + .3·sin(2.3πft+2i) + .15·sin(5.1πft+3i)` for `{base .60, amp .065, f 1.7, #4a3353}`, `{.645, .045, 2.6, #2e2238}`, `{.685, .028, 4.1, #1c1626}`; sea gradient from `0.70H` `#3a2a46 → #17132a @.45 → #0a0912`; 90 sun-path streaks (1–3.5px tall, widening toward the bottom, `rgba(255,170–220,120,.05–.27)`) centred on the sun x; two black (`#07060a`) foreground rock silhouettes bottom-left/right; radial vignette to `rgba(0,0,0,.45)`; a 128px grain tile (alpha 26) composited with `overlay`.
2. Derive the "before": `g.filter = 'saturate(0.2) brightness(0.72) contrast(0.92)'; g.drawImage(after, 0, 0); g.filter = 'none';` then, only if `HAZE` (true) is true, `g.fillStyle = 'rgba(78,96,124,.28)'; g.fillRect(…)`. If `!('filter' in g)`, draw unfiltered and desaturate per pixel (`l = .299r + .587g + .114b`, mix 20% colour, ×0.72).
3. State object `state = { p: 0.5 }`. `render(fromDrag)`: `top.style.clipPath = inset(0 ${(1-p)*100}% 0 0)`; unless `fromDrag`, `gsap.set(handle, { x: p * frame.clientWidth - 1 })` and `draggable.update()`; `labelBefore.style.opacity = clamp(0, 1, (p - 0.08) / 0.2)`, `labelAfter.style.opacity = clamp(0, 1, ((1-p) - 0.08) / 0.2)`; update `aria-valuenow` (0–100) and `aria-valuetext`.
4. Draggable:
```js
Draggable.create(handle, { type: 'x', bounds: frame, cursor: 'ew-resize', zIndexBoost: false,
onPress() { gsap.killTweensOf(state); frame.classList.add('is-dragging'); },
onDrag() { state.p = clamp(0, 1, (this.x + 1) / frame.clientWidth); render(true); },
onRelease() { frame.classList.remove('is-dragging'); } });
```
No inertia.
5. Intro (not under reduced motion): `gsap.from('.ba__head > *', { y: 24, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08 })`, `gsap.from(frame, { y: 30, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`, then `gsap.timeline({ delay: 0.3 }).to(state, { p: 0.6, duration: 0.8, ease: 'power3.inOut', onUpdate: render }).to(state, { p: 0.5, duration: 0.8, ease: 'power3.inOut', onUpdate: render })` — 2 × 0.8s total sweep.
6. `animateTo(p, duration = 0.6, ease = 'power3.out')`: `gsap.to(state, { p: clamp(0,1,p), duration, ease, overwrite: true, onUpdate: render })`; instant `render()` under reduced motion.
**Interaction:** Drag the handle (pointer or touch; `touch-action: none` on the handle, `pan-y` on the frame so vertical page scroll still works). Click on the frame (outside the handle) → `animateTo((clientX − rect.left) / rect.width)` in 0.6s. Keyboard on the focused handle: ArrowLeft/Down −2%, ArrowRight/Up +2%, PageDown −10%, PageUp +10%, Home 0%, End 100%, each `animateTo(…, 0.25, 'power2.out')`. Knob hover 1.08×, drag 0.94×. Window resize re-renders so the handle stays at the same percentage.
**Responsive behavior:** The frame is fluid (`width: 100%; aspect-ratio: 16/10`), so at 375px it is ~343×214px; the header collapses to one column, the knob shrinks to 40px. The canvases keep their 1600×1000 backing store and are scaled by CSS. No `gsap.matchMedia()` branch is needed — Draggable bounds and `p` are relative.
**Accessibility:** The handle is `role="slider"` with `aria-valuemin/max/now`, `aria-valuetext` ("62% before, 38% after"), `aria-orientation="horizontal"` and a visible focus ring; it is fully keyboard operable. Labels and canvases are `aria-hidden` (the caption carries the description). With `prefers-reduced-motion: reduce`: no intro tweens and no auto-sweep; click-to-jump and keyboard moves apply instantly; the knob scale transition is disabled. Dragging still works because it is user-driven.
**Performance rules:** One `clip-path` style write and one transform per drag frame; no canvas redraw after the initial paint (the two canvases are painted once, ~1600×1000). `will-change: clip-path` on the top layer and `will-change: transform` on the handle. Everything is created inside `gsap.context()` (Draggable included) and `ctx.revert()` on unmount; the resize listener is removed by the context's cleanup function.
**Customization knobs:**
- `START` (0.5), `SWEEP_TO` (0.6) and the sweep leg duration (`SWEEP` = 0.8, played twice).
- `KEY_STEP` (0.02).
- Click-to-jump duration/ease (0.6, `power3.out`).
- The "before" filter string (`saturate(0.2) brightness(0.72) contrast(0.92)`) and the cool haze (`HAZE` = true; colour `rgba(78,96,124,.28)`).
- Handle colour `--accent` (#d4ff3a); knob size (44px).
- Swap the canvases for two `<img>` elements with the same aspect ratio — the clip logic is unchanged.
**Acceptance criteria:**
- [ ] Both layers are pixel-aligned; the split line is exactly at the handle on every frame while dragging.
- [ ] Handle cannot leave the frame (0%–100%); labels fade near the edges.
- [ ] On load the split sweeps 0.5 → 0.6 → 0.5 over 2 × 0.8s, then rests at 0.5.
- [ ] Click anywhere jumps the split there in 0.6s; arrow keys move 2% and update `aria-valuenow`.
- [ ] After a programmatic move, the next drag starts from the current position (no jump).
- [ ] Reduced motion: no sweep, instant jumps; no console errors; works at 375px. Add this effect to my existing site/section: **Before / After Drag Slider**. Keep my two images (same dimensions); stack them in a `.ba__frame` (`position: relative; aspect-ratio` of the images; `overflow: hidden`) with the "before" image inside a `.ba__top` wrapper that has `clip-path: inset(0 50% 0 0)`, plus a 2px `#d4ff3a` `.ba__handle` (`role="slider"`, `tabindex="0"`, `aria-valuemin/max/now`) containing a 44px round knob of the same colour. Load GSAP 3.13+ and Draggable. Keep a `state = { p: 0.5 }`; `render()` writes `clip-path: inset(0 ${(1-p)*100}% 0 0)` and `gsap.set(handle, { x: p * frame.clientWidth })`. `Draggable.create(handle, { type: 'x', bounds: frame, onDrag() { state.p = this.x / frame.clientWidth; render(true); } })`; call `draggable.update()` after programmatic moves. Click on the frame → `gsap.to(state, { p, duration: 0.6, ease: 'power3.out', onUpdate: render })`; arrow keys ±2%. On load run `gsap.timeline({ delay: 0.3 }).to(state, { p: 0.6, duration: 0.8, ease: 'power3.inOut' }).to(state, { p: 0.5, duration: 0.8, ease: 'power3.inOut' })` unless `prefers-reduced-motion`. If my "before" image is derived on canvas, apply the cool haze overlay only when `HAZE` = true. Wrap everything in `gsap.context()` and revert on unmount. Do not change my fonts or colours outside the frame. ### EFFECT (React / Next.js): Before / After Drag Slider
Build **Before / After Drag Slider** 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/BeforeAfterDragSlider.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { Draggable } from "gsap/Draggable";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, Draggable);
export default function BeforeAfterDragSlider() {
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="ba">
{/* 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 (`BeforeAfterDragSlider.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: Before / After Drag Slider
**What it does (visual):** A dark page with a heading ("The grade, before and after") and a short lead, then a wide 16:10 rounded frame. Inside is a dusk seascape — a plum-to-apricot sky, a low sun with a warm glow, three layered mountain ridges, a dark sea carrying a streaked sun path, black foreground rocks, a vignette and fine grain. The frame is split by a 2px vertical line in the accent colour (#d4ff3a) with a 44px circle of the same colour in the middle holding two small chevrons. Left of the line the same picture is flat and desaturated, with a cool blue-grey haze when `HAZE` is true (the ungraded "before"); right of the line it is the warm graded "after". Two frosted pill labels sit in the top corners: "Before" (left) and "After" (right). On load the heading fades up, the frame rises 30px, and 0.3s later the handle glides from the resting split (0.5 of the width) to 0.6 and back over 2 × 0.8s, revealing more of one side then settling — a silent hint that it can be dragged. Dragging the circle moves the split in real time; the "Before" label fades out as the handle nears the left edge and "After" fades as it nears the right. Clicking anywhere in the frame slides the handle there in 0.6s. The knob scales to 108% on hover and 94% while dragging. With the handle focused, arrow keys nudge it 2% per press.
**Tech stack:**
- GSAP 3.13+ with the Draggable plugin (free since 3.13).
- 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/Draggable.min.js"></script>`
- npm: `import gsap from "gsap"; import { Draggable } from "gsap/Draggable"; gsap.registerPlugin(Draggable);`
- Canvas 2D for the procedural scene and for deriving the "before" (`ctx.filter`, with a per-pixel fallback). In production use two real `<img>`s with identical dimensions instead of the canvases.
**HTML structure:**
```html
<section class="ba">
<header class="ba__head">
<p class="ba__eyebrow">Layout · Draggable + clip-path</p>
<h1 class="ba__title">The grade, <em>before and after</em></h1>
<p class="ba__lead">Drag the handle, click anywhere in the frame, or use the arrow keys.</p>
</header>
<div class="ba__frame">
<canvas class="ba__canvas ba__canvas--after" width="1600" height="1000" aria-hidden="true"></canvas>
<div class="ba__top"><canvas class="ba__canvas ba__canvas--before" width="1600" height="1000" aria-hidden="true"></canvas></div>
<span class="ba__label ba__label--before" aria-hidden="true">Before</span>
<span class="ba__label ba__label--after" aria-hidden="true">After</span>
<div class="ba__handle" role="slider" tabindex="0" aria-label="Comparison position" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" aria-valuetext="50% before, 50% after" aria-orientation="horizontal">
<span class="ba__knob" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 7l-5 5 5 5M15 7l5 5-5 5"/></svg></span>
</div>
</div>
<p class="ba__caption"><span class="ba__caption-num">01</span> Kalk Bay, 06:41 — procedural frame</p>
</section>
```
The "before" layer is on top and is clipped; the "after" layer underneath is always fully painted.
**CSS requirements:**
- `.ba`: `min-height: 100vh; max-width: 1400px; margin-inline: auto; padding: clamp(2rem, 6vw, 5rem) clamp(1rem, 5vw, 4rem); display: grid; align-content: center; gap: clamp(1.5rem, 3vw, 2.5rem);`
- `.ba__head`: two-column grid (`minmax(0,1fr) minmax(0,1fr)`, `align-items: end`); eyebrow spans both columns; `.ba__title` display serif 300, `font-size: clamp(2.25rem, 5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95` with an italic muted `<em>`; `.ba__lead` muted, `max-width: 46ch; justify-self: end`.
- `.ba__frame`: `position: relative; width: min(100%, calc((100vh - 16rem) * 1.6)); margin-inline: auto; aspect-ratio: 16 / 10; border-radius: 14px; overflow: hidden; background: var(--surface); border: 1px solid var(--line); user-select: none; touch-action: pan-y; cursor: ew-resize;`
- `.ba__canvas`: `position: absolute; inset: 0; width: 100%; height: 100%;`
- `.ba__top`: `position: absolute; inset: 0; clip-path: inset(0 50% 0 0); will-change: clip-path;` — JS writes `inset(0 ${(1 - p) * 100}% 0 0)`.
- `.ba__label`: `position: absolute; top: clamp(.75rem, 2vw, 1.25rem); font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .14em; padding: .45em .8em; border-radius: 999px; color: #fff; background: rgba(0,0,0,.42); backdrop-filter: blur(6px); pointer-events: none;` `--before { left: … }`, `--after { right: … }`.
- `.ba__handle`: `position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: var(--accent); cursor: ew-resize; touch-action: none; will-change: transform;` `::before { content: ''; position: absolute; inset: 0 -22px; }` widens the hit area to 46px.
- `.ba__knob`: `position: absolute; left: 50%; top: 50%; width: 44px; height: 44px; transform: translate(-50%, -50%); display: grid; place-items: center; border-radius: 50%; background: var(--accent); color: #0a0a0a; box-shadow: 0 6px 24px rgba(0,0,0,.35); transition: transform .3s cubic-bezier(.2,.7,.1,1);` hover/focus-visible → `scale(1.08)`, `.is-dragging` → `scale(.94)`. Focus ring: `box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent)` on the knob.
- `.ba__caption`: mono 11px muted; the index is `var(--accent)`.
- Accent colour (handle line, knob, caption index, focus ring): `--accent: #d4ff3a`.
**Animation spec (exact values):**
1. Paint the scene on the 1600×1000 "after" canvas (seeded `mulberry32(7)` RNG so it is deterministic): sky `linearGradient(0→H·0.70)` stops `#171231 @0, #5d2e4c @.42, #d9704a @.78, #f6c58d @1`; sun glow `radialGradient` at `(0.62W, 0.585H)` radius `0.38W` from `rgba(255,220,160,.95)` → `rgba(255,170,100,.4) @.22` → transparent; sun disc radius `0.052H` `#ffe6b8`; three ridges as filled polylines `y = H·(base − amp·wave)` with `wave = .55·sin(πft+i) + .3·sin(2.3πft+2i) + .15·sin(5.1πft+3i)` for `{base .60, amp .065, f 1.7, #4a3353}`, `{.645, .045, 2.6, #2e2238}`, `{.685, .028, 4.1, #1c1626}`; sea gradient from `0.70H` `#3a2a46 → #17132a @.45 → #0a0912`; 90 sun-path streaks (1–3.5px tall, widening toward the bottom, `rgba(255,170–220,120,.05–.27)`) centred on the sun x; two black (`#07060a`) foreground rock silhouettes bottom-left/right; radial vignette to `rgba(0,0,0,.45)`; a 128px grain tile (alpha 26) composited with `overlay`.
2. Derive the "before": `g.filter = 'saturate(0.2) brightness(0.72) contrast(0.92)'; g.drawImage(after, 0, 0); g.filter = 'none';` then, only if `HAZE` (true) is true, `g.fillStyle = 'rgba(78,96,124,.28)'; g.fillRect(…)`. If `!('filter' in g)`, draw unfiltered and desaturate per pixel (`l = .299r + .587g + .114b`, mix 20% colour, ×0.72).
3. State object `state = { p: 0.5 }`. `render(fromDrag)`: `top.style.clipPath = inset(0 ${(1-p)*100}% 0 0)`; unless `fromDrag`, `gsap.set(handle, { x: p * frame.clientWidth - 1 })` and `draggable.update()`; `labelBefore.style.opacity = clamp(0, 1, (p - 0.08) / 0.2)`, `labelAfter.style.opacity = clamp(0, 1, ((1-p) - 0.08) / 0.2)`; update `aria-valuenow` (0–100) and `aria-valuetext`.
4. Draggable:
```js
Draggable.create(handle, { type: 'x', bounds: frame, cursor: 'ew-resize', zIndexBoost: false,
onPress() { gsap.killTweensOf(state); frame.classList.add('is-dragging'); },
onDrag() { state.p = clamp(0, 1, (this.x + 1) / frame.clientWidth); render(true); },
onRelease() { frame.classList.remove('is-dragging'); } });
```
No inertia.
5. Intro (not under reduced motion): `gsap.from('.ba__head > *', { y: 24, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08 })`, `gsap.from(frame, { y: 30, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`, then `gsap.timeline({ delay: 0.3 }).to(state, { p: 0.6, duration: 0.8, ease: 'power3.inOut', onUpdate: render }).to(state, { p: 0.5, duration: 0.8, ease: 'power3.inOut', onUpdate: render })` — 2 × 0.8s total sweep.
6. `animateTo(p, duration = 0.6, ease = 'power3.out')`: `gsap.to(state, { p: clamp(0,1,p), duration, ease, overwrite: true, onUpdate: render })`; instant `render()` under reduced motion.
**Interaction:** Drag the handle (pointer or touch; `touch-action: none` on the handle, `pan-y` on the frame so vertical page scroll still works). Click on the frame (outside the handle) → `animateTo((clientX − rect.left) / rect.width)` in 0.6s. Keyboard on the focused handle: ArrowLeft/Down −2%, ArrowRight/Up +2%, PageDown −10%, PageUp +10%, Home 0%, End 100%, each `animateTo(…, 0.25, 'power2.out')`. Knob hover 1.08×, drag 0.94×. Window resize re-renders so the handle stays at the same percentage.
**Responsive behavior:** The frame is fluid (`width: 100%; aspect-ratio: 16/10`), so at 375px it is ~343×214px; the header collapses to one column, the knob shrinks to 40px. The canvases keep their 1600×1000 backing store and are scaled by CSS. No `gsap.matchMedia()` branch is needed — Draggable bounds and `p` are relative.
**Accessibility:** The handle is `role="slider"` with `aria-valuemin/max/now`, `aria-valuetext` ("62% before, 38% after"), `aria-orientation="horizontal"` and a visible focus ring; it is fully keyboard operable. Labels and canvases are `aria-hidden` (the caption carries the description). With `prefers-reduced-motion: reduce`: no intro tweens and no auto-sweep; click-to-jump and keyboard moves apply instantly; the knob scale transition is disabled. Dragging still works because it is user-driven.
**Performance rules:** One `clip-path` style write and one transform per drag frame; no canvas redraw after the initial paint (the two canvases are painted once, ~1600×1000). `will-change: clip-path` on the top layer and `will-change: transform` on the handle. Everything is created inside `gsap.context()` (Draggable included) and `ctx.revert()` on unmount; the resize listener is removed by the context's cleanup function.
**Customization knobs:**
- `START` (0.5), `SWEEP_TO` (0.6) and the sweep leg duration (`SWEEP` = 0.8, played twice).
- `KEY_STEP` (0.02).
- Click-to-jump duration/ease (0.6, `power3.out`).
- The "before" filter string (`saturate(0.2) brightness(0.72) contrast(0.92)`) and the cool haze (`HAZE` = true; colour `rgba(78,96,124,.28)`).
- Handle colour `--accent` (#d4ff3a); knob size (44px).
- Swap the canvases for two `<img>` elements with the same aspect ratio — the clip logic is unchanged.
**Acceptance criteria:**
- [ ] Both layers are pixel-aligned; the split line is exactly at the handle on every frame while dragging.
- [ ] Handle cannot leave the frame (0%–100%); labels fade near the edges.
- [ ] On load the split sweeps 0.5 → 0.6 → 0.5 over 2 × 0.8s, then rests at 0.5.
- [ ] Click anywhere jumps the split there in 0.6s; arrow keys move 2% and update `aria-valuenow`.
- [ ] After a programmatic move, the next drag starts from the current position (no jump).
- [ ] Reduced motion: no sweep, instant jumps; no console errors; works at 375px.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<section class="ba">
<header class="ba__head">
<p class="ba__eyebrow">Layout · Draggable + clip-path</p>
<h1 class="ba__title">The grade, <em>before and after</em></h1>
<p class="ba__lead">Drag the handle, click anywhere in the frame, or use the arrow keys. The flat log-style "before" is derived from the graded frame on canvas, so both halves are always pixel-aligned.</p>
</header>
<div class="ba__frame">
<canvas class="ba__canvas ba__canvas--after" width="1600" height="1000" aria-hidden="true"></canvas>
<div class="ba__top">
<canvas class="ba__canvas ba__canvas--before" width="1600" height="1000" aria-hidden="true"></canvas>
</div>
<span class="ba__label ba__label--before" aria-hidden="true">Before</span>
<span class="ba__label ba__label--after" aria-hidden="true">After</span>
<div class="ba__handle" role="slider" tabindex="0" aria-label="Comparison position" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" aria-valuetext="50% before, 50% after" aria-orientation="horizontal">
<span class="ba__knob" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 7l-5 5 5 5M15 7l5 5-5 5"/></svg>
</span>
</div>
</div>
<p class="ba__caption"><span class="ba__caption-num">01</span> Kalk Bay, 06:41 — procedural frame, 1600 × 1000 · grade: warm shadows, lifted highlights</p>
</section>
```
```css
.ba { min-height: 100vh; max-width: 1400px; margin-inline: auto; padding: clamp(2rem, 6vw, 5rem) clamp(1rem, 5vw, 4rem) clamp(3rem, 6vw, 5rem); display: grid; align-content: center; gap: clamp(1.5rem, 3vw, 2.5rem); }
.ba__head { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1rem 3rem; align-items: end; }
.ba__eyebrow { grid-column: 1 / -1; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.ba__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.25rem, 5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95; }
.ba__title em { font-style: italic; color: var(--muted); }
.ba__lead { max-width: 46ch; color: var(--muted); font-size: clamp(.9rem, 1.05vw, 1.05rem); line-height: 1.55; justify-self: end; }
.ba__frame { position: relative; width: min(100%, calc((100vh - 16rem) * 1.6)); margin-inline: auto; aspect-ratio: 16 / 10; border-radius: 14px; overflow: hidden; background: var(--surface); border: 1px solid var(--line); user-select: none; -webkit-user-select: none; touch-action: pan-y; cursor: ew-resize; }
.ba__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ba__top { position: absolute; inset: 0; clip-path: inset(0 50% 0 0); will-change: clip-path; }
.ba__label { position: absolute; top: clamp(.75rem, 2vw, 1.25rem); font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .14em; padding: .45em .8em; border-radius: 999px; color: #fff; background: rgba(0,0,0,.42); backdrop-filter: blur(6px); pointer-events: none; }
.ba__label--before { left: clamp(.75rem, 2vw, 1.25rem); }
.ba__label--after { right: clamp(.75rem, 2vw, 1.25rem); }
.ba__handle { position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: var(--knob-accent, var(--accent)); cursor: ew-resize; touch-action: none; will-change: transform; }
.ba__handle::before { content: ''; position: absolute; inset: 0 -22px; }
.ba__handle:focus { outline: none; }
.ba__handle:focus-visible .ba__knob { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--knob-accent, var(--accent)); }
.ba__knob { position: absolute; left: 50%; top: 50%; width: 44px; height: 44px; transform: translate(-50%, -50%); display: grid; place-items: center; border-radius: 50%; background: var(--knob-accent, var(--accent)); color: #0a0a0a; box-shadow: 0 6px 24px rgba(0,0,0,.35); transition: transform .3s cubic-bezier(.2,.7,.1,1); }
.ba__frame:hover .ba__knob, .ba__handle:focus-visible .ba__knob { transform: translate(-50%, -50%) scale(1.08); }
.ba__frame.is-dragging .ba__knob { transform: translate(-50%, -50%) scale(.94); }
.ba__caption { font-family: var(--font-mono); font-size: 11px; letter-spacing: .06em; color: var(--muted); }
.ba__caption-num { color: var(--knob-accent, var(--accent)); margin-right: .75em; }
@media (max-width: 767px) {
.ba__head { grid-template-columns: 1fr; align-items: start; }
.ba__lead { justify-self: start; }
.ba__knob { width: 40px; height: 40px; }
}
@media (prefers-reduced-motion: reduce) {
.ba__knob { transition: none; }
}
```
```js
// Before / After Drag Slider — one procedural scene, a canvas-filtered twin, a Draggable clip handle.
gsap.registerPlugin(Draggable);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { start: 0.5, sweepTo: 0.6, sweepDuration: 0.8, jumpDuration: 0.6, accent: '#d4ff3a', haze: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const KEY_STEP = 0.02; // arrow-key step
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.ba');
const frame = document.querySelector('.ba__frame');
const topLayer = document.querySelector('.ba__top');
const handle = document.querySelector('.ba__handle');
const afterCanvas = document.querySelector('.ba__canvas--after');
const beforeCanvas = document.querySelector('.ba__canvas--before');
const labelBefore = document.querySelector('.ba__label--before');
const labelAfter = document.querySelector('.ba__label--after');
/* ---------- 1. Paint the scene once ---------- */
function mulberry32(seed) {
return () => { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
function paintAfter(c) {
const W = c.width, H = c.height, g = c.getContext('2d'), rnd = mulberry32(7);
const sunX = W * 0.62, sunY = H * 0.585, seaTop = H * 0.70;
const sky = g.createLinearGradient(0, 0, 0, seaTop);
sky.addColorStop(0, '#171231'); sky.addColorStop(0.42, '#5d2e4c'); sky.addColorStop(0.78, '#d9704a'); sky.addColorStop(1, '#f6c58d');
g.fillStyle = sky; g.fillRect(0, 0, W, H);
const glow = g.createRadialGradient(sunX, sunY, 0, sunX, sunY, W * 0.38);
glow.addColorStop(0, 'rgba(255,220,160,.95)'); glow.addColorStop(0.22, 'rgba(255,170,100,.4)'); glow.addColorStop(1, 'rgba(255,140,80,0)');
g.fillStyle = glow; g.fillRect(0, 0, W, H);
g.fillStyle = '#ffe6b8'; g.beginPath(); g.arc(sunX, sunY, H * 0.052, 0, Math.PI * 2); g.fill();
// Distant ridges — three layered silhouettes built from summed sines.
const ridges = [
{ y: 0.60, amp: 0.065, f: 1.7, col: '#4a3353' },
{ y: 0.645, amp: 0.045, f: 2.6, col: '#2e2238' },
{ y: 0.685, amp: 0.028, f: 4.1, col: '#1c1626' },
];
ridges.forEach((r, i) => {
g.fillStyle = r.col; g.beginPath(); g.moveTo(0, H);
for (let x = 0; x <= W; x += 6) {
const t = x / W;
const wave = 0.55 * Math.sin(t * Math.PI * r.f + i) + 0.3 * Math.sin(t * Math.PI * r.f * 2.3 + i * 2) + 0.15 * Math.sin(t * Math.PI * r.f * 5.1 + i * 3);
g.lineTo(x, H * (r.y - r.amp * wave));
}
g.lineTo(W, H); g.closePath(); g.fill();
});
// Sea
const sea = g.createLinearGradient(0, seaTop, 0, H);
sea.addColorStop(0, '#3a2a46'); sea.addColorStop(0.45, '#17132a'); sea.addColorStop(1, '#0a0912');
g.fillStyle = sea; g.fillRect(0, seaTop, W, H - seaTop);
// Sun path on the water — many thin streaks that widen toward the camera.
for (let i = 0; i < 90; i++) {
const v = rnd(); const y = seaTop + v * v * (H - seaTop) * 0.9;
const w = (0.06 + rnd() * 0.3) * W * (0.25 + v * 0.75);
g.fillStyle = `rgba(255,${170 + Math.round(rnd() * 50)},120,${0.05 + rnd() * 0.22})`;
g.fillRect(sunX - w / 2 + (rnd() - 0.5) * W * 0.06, y, w, 1 + rnd() * 2.5);
}
// Foreground rocks
g.fillStyle = '#07060a';
g.beginPath(); g.moveTo(0, H); g.lineTo(0, H * 0.78);
for (let x = 0; x <= W * 0.42; x += 10) g.lineTo(x, H * (0.80 + 0.07 * Math.sin(x / W * 11) + 0.04 * Math.sin(x / W * 29)) + (x / (W * 0.42)) * H * 0.14);
g.lineTo(W * 0.42, H); g.closePath(); g.fill();
g.beginPath(); g.moveTo(W, H); g.lineTo(W, H * 0.86);
for (let x = W; x >= W * 0.7; x -= 10) g.lineTo(x, H * (0.88 + 0.05 * Math.sin(x / W * 17)) + ((W - x) / (W * 0.3)) * H * 0.1);
g.lineTo(W * 0.7, H); g.closePath(); g.fill();
// Vignette + grain
const vig = g.createRadialGradient(W / 2, H / 2, H * 0.3, W / 2, H / 2, W * 0.75);
vig.addColorStop(0, 'rgba(0,0,0,0)'); vig.addColorStop(1, 'rgba(0,0,0,.45)');
g.fillStyle = vig; g.fillRect(0, 0, W, H);
const tile = document.createElement('canvas'); tile.width = tile.height = 128;
const tg = tile.getContext('2d'); const img = tg.createImageData(128, 128);
for (let i = 0; i < img.data.length; i += 4) { const n = 200 + rnd() * 55; img.data[i] = img.data[i + 1] = img.data[i + 2] = n; img.data[i + 3] = 26; }
tg.putImageData(img, 0, 0);
g.globalCompositeOperation = 'overlay'; g.fillStyle = g.createPattern(tile, 'repeat'); g.fillRect(0, 0, W, H);
g.globalCompositeOperation = 'source-over';
}
/* ---------- 2. Derive the "before" from the "after" ---------- */
function deriveBefore(src, dst, haze = true) {
const g = dst.getContext('2d');
g.clearRect(0, 0, dst.width, dst.height);
if ('filter' in g) {
g.filter = 'saturate(0.2) brightness(0.72) contrast(0.92)';
g.drawImage(src, 0, 0);
g.filter = 'none';
} else {
g.drawImage(src, 0, 0);
const img = g.getImageData(0, 0, dst.width, dst.height), d = img.data;
for (let i = 0; i < d.length; i += 4) {
const l = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
d[i] = (l + (d[i] - l) * 0.2) * 0.72; d[i + 1] = (l + (d[i + 1] - l) * 0.2) * 0.72; d[i + 2] = (l + (d[i + 2] - l) * 0.2) * 0.72;
}
g.putImageData(img, 0, 0);
}
if (haze) { g.fillStyle = 'rgba(78,96,124,.28)'; g.fillRect(0, 0, dst.width, dst.height); } // flat, cool log haze
}
paintAfter(afterCanvas); // the graded scene is painted once; the "before" is re-derived per build (cheap)
/* ---------- 3. Position state ---------- */
const state = { p: DEFAULTS.start };
let drag;
let JUMP_DURATION = DEFAULTS.jumpDuration;
const width = () => frame.clientWidth;
function render(fromDrag = false) {
const p = state.p;
topLayer.style.clipPath = `inset(0 ${(1 - p) * 100}% 0 0)`;
if (!fromDrag) { gsap.set(handle, { x: p * width() - 1 }); drag?.update(); }
labelBefore.style.opacity = gsap.utils.clamp(0, 1, (p - 0.08) / 0.2);
labelAfter.style.opacity = gsap.utils.clamp(0, 1, ((1 - p) - 0.08) / 0.2);
const pct = Math.round(p * 100);
handle.setAttribute('aria-valuenow', pct);
handle.setAttribute('aria-valuetext', `${pct}% before, ${100 - pct}% after`);
}
function animateTo(p, duration = JUMP_DURATION, ease = 'power3.out') {
p = gsap.utils.clamp(0, 1, p);
if (reduced) { state.p = p; render(); return; }
gsap.to(state, { p, duration, ease, overwrite: true, onUpdate: () => render() });
}
let ctx;
function build() {
ctx?.revert();
gsap.killTweensOf(state);
section.style.setProperty('--knob-accent', K('accent'));
const START = K('start'); // initial split
const SWEEP_TO = K('sweepTo'); // intro sweep target
const SWEEP = K('sweepDuration'); // per leg
JUMP_DURATION = K('jumpDuration'); // click-to-jump
deriveBefore(afterCanvas, beforeCanvas, K('haze'));
state.p = START;
ctx = gsap.context(() => {
const ac = new AbortController();
render();
drag = Draggable.create(handle, {
type: 'x', bounds: frame, cursor: 'ew-resize', zIndexBoost: false,
onPress() { gsap.killTweensOf(state); frame.classList.add('is-dragging'); },
onDrag() { state.p = gsap.utils.clamp(0, 1, (this.x + 1) / width()); render(true); },
onRelease() { frame.classList.remove('is-dragging'); },
})[0];
// Click (not a drag on the handle) jumps the split to the pointer.
frame.addEventListener('click', (e) => {
if (handle.contains(e.target)) return;
const r = frame.getBoundingClientRect();
animateTo((e.clientX - r.left) / r.width);
}, { signal: ac.signal });
handle.addEventListener('keydown', (e) => {
const map = { ArrowLeft: state.p - KEY_STEP, ArrowRight: state.p + KEY_STEP, ArrowDown: state.p - KEY_STEP, ArrowUp: state.p + KEY_STEP, PageDown: state.p - 0.1, PageUp: state.p + 0.1, Home: 0, End: 1 };
if (!(e.key in map)) return;
e.preventDefault();
animateTo(map[e.key], 0.25, 'power2.out');
}, { signal: ac.signal });
window.addEventListener('resize', () => render(), { signal: ac.signal });
if (!reduced) {
gsap.from('.ba__head > *', { y: 24, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08 });
gsap.from(frame, { y: 30, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
// Intro sweep: START → SWEEP_TO → START, 2 × SWEEP seconds, teaches the gesture.
gsap.timeline({ delay: 0.3 })
.to(state, { p: SWEEP_TO, duration: SWEEP, ease: 'power3.inOut', onUpdate: () => render() })
.to(state, { p: START, duration: SWEEP, ease: 'power3.inOut', onUpdate: () => render() });
}
return () => {
ac.abort(); // listeners go with the context so rebuilds never stack handlers
drag?.kill(); drag = null; // Draggable is re-created per build
frame.classList.remove('is-dragging');
};
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (single HTML file): Before / After Drag Slider
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Before / After Drag Slider** 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>Before / After Drag Slider</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/Draggable.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: Before / After Drag Slider
**What it does (visual):** A dark page with a heading ("The grade, before and after") and a short lead, then a wide 16:10 rounded frame. Inside is a dusk seascape — a plum-to-apricot sky, a low sun with a warm glow, three layered mountain ridges, a dark sea carrying a streaked sun path, black foreground rocks, a vignette and fine grain. The frame is split by a 2px vertical line in the accent colour (#d4ff3a) with a 44px circle of the same colour in the middle holding two small chevrons. Left of the line the same picture is flat and desaturated, with a cool blue-grey haze when `HAZE` is true (the ungraded "before"); right of the line it is the warm graded "after". Two frosted pill labels sit in the top corners: "Before" (left) and "After" (right). On load the heading fades up, the frame rises 30px, and 0.3s later the handle glides from the resting split (0.5 of the width) to 0.6 and back over 2 × 0.8s, revealing more of one side then settling — a silent hint that it can be dragged. Dragging the circle moves the split in real time; the "Before" label fades out as the handle nears the left edge and "After" fades as it nears the right. Clicking anywhere in the frame slides the handle there in 0.6s. The knob scales to 108% on hover and 94% while dragging. With the handle focused, arrow keys nudge it 2% per press.
**Tech stack:**
- GSAP 3.13+ with the Draggable plugin (free since 3.13).
- 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/Draggable.min.js"></script>`
- npm: `import gsap from "gsap"; import { Draggable } from "gsap/Draggable"; gsap.registerPlugin(Draggable);`
- Canvas 2D for the procedural scene and for deriving the "before" (`ctx.filter`, with a per-pixel fallback). In production use two real `<img>`s with identical dimensions instead of the canvases.
**HTML structure:**
```html
<section class="ba">
<header class="ba__head">
<p class="ba__eyebrow">Layout · Draggable + clip-path</p>
<h1 class="ba__title">The grade, <em>before and after</em></h1>
<p class="ba__lead">Drag the handle, click anywhere in the frame, or use the arrow keys.</p>
</header>
<div class="ba__frame">
<canvas class="ba__canvas ba__canvas--after" width="1600" height="1000" aria-hidden="true"></canvas>
<div class="ba__top"><canvas class="ba__canvas ba__canvas--before" width="1600" height="1000" aria-hidden="true"></canvas></div>
<span class="ba__label ba__label--before" aria-hidden="true">Before</span>
<span class="ba__label ba__label--after" aria-hidden="true">After</span>
<div class="ba__handle" role="slider" tabindex="0" aria-label="Comparison position" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" aria-valuetext="50% before, 50% after" aria-orientation="horizontal">
<span class="ba__knob" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 7l-5 5 5 5M15 7l5 5-5 5"/></svg></span>
</div>
</div>
<p class="ba__caption"><span class="ba__caption-num">01</span> Kalk Bay, 06:41 — procedural frame</p>
</section>
```
The "before" layer is on top and is clipped; the "after" layer underneath is always fully painted.
**CSS requirements:**
- `.ba`: `min-height: 100vh; max-width: 1400px; margin-inline: auto; padding: clamp(2rem, 6vw, 5rem) clamp(1rem, 5vw, 4rem); display: grid; align-content: center; gap: clamp(1.5rem, 3vw, 2.5rem);`
- `.ba__head`: two-column grid (`minmax(0,1fr) minmax(0,1fr)`, `align-items: end`); eyebrow spans both columns; `.ba__title` display serif 300, `font-size: clamp(2.25rem, 5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95` with an italic muted `<em>`; `.ba__lead` muted, `max-width: 46ch; justify-self: end`.
- `.ba__frame`: `position: relative; width: min(100%, calc((100vh - 16rem) * 1.6)); margin-inline: auto; aspect-ratio: 16 / 10; border-radius: 14px; overflow: hidden; background: var(--surface); border: 1px solid var(--line); user-select: none; touch-action: pan-y; cursor: ew-resize;`
- `.ba__canvas`: `position: absolute; inset: 0; width: 100%; height: 100%;`
- `.ba__top`: `position: absolute; inset: 0; clip-path: inset(0 50% 0 0); will-change: clip-path;` — JS writes `inset(0 ${(1 - p) * 100}% 0 0)`.
- `.ba__label`: `position: absolute; top: clamp(.75rem, 2vw, 1.25rem); font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .14em; padding: .45em .8em; border-radius: 999px; color: #fff; background: rgba(0,0,0,.42); backdrop-filter: blur(6px); pointer-events: none;` `--before { left: … }`, `--after { right: … }`.
- `.ba__handle`: `position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: var(--accent); cursor: ew-resize; touch-action: none; will-change: transform;` `::before { content: ''; position: absolute; inset: 0 -22px; }` widens the hit area to 46px.
- `.ba__knob`: `position: absolute; left: 50%; top: 50%; width: 44px; height: 44px; transform: translate(-50%, -50%); display: grid; place-items: center; border-radius: 50%; background: var(--accent); color: #0a0a0a; box-shadow: 0 6px 24px rgba(0,0,0,.35); transition: transform .3s cubic-bezier(.2,.7,.1,1);` hover/focus-visible → `scale(1.08)`, `.is-dragging` → `scale(.94)`. Focus ring: `box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent)` on the knob.
- `.ba__caption`: mono 11px muted; the index is `var(--accent)`.
- Accent colour (handle line, knob, caption index, focus ring): `--accent: #d4ff3a`.
**Animation spec (exact values):**
1. Paint the scene on the 1600×1000 "after" canvas (seeded `mulberry32(7)` RNG so it is deterministic): sky `linearGradient(0→H·0.70)` stops `#171231 @0, #5d2e4c @.42, #d9704a @.78, #f6c58d @1`; sun glow `radialGradient` at `(0.62W, 0.585H)` radius `0.38W` from `rgba(255,220,160,.95)` → `rgba(255,170,100,.4) @.22` → transparent; sun disc radius `0.052H` `#ffe6b8`; three ridges as filled polylines `y = H·(base − amp·wave)` with `wave = .55·sin(πft+i) + .3·sin(2.3πft+2i) + .15·sin(5.1πft+3i)` for `{base .60, amp .065, f 1.7, #4a3353}`, `{.645, .045, 2.6, #2e2238}`, `{.685, .028, 4.1, #1c1626}`; sea gradient from `0.70H` `#3a2a46 → #17132a @.45 → #0a0912`; 90 sun-path streaks (1–3.5px tall, widening toward the bottom, `rgba(255,170–220,120,.05–.27)`) centred on the sun x; two black (`#07060a`) foreground rock silhouettes bottom-left/right; radial vignette to `rgba(0,0,0,.45)`; a 128px grain tile (alpha 26) composited with `overlay`.
2. Derive the "before": `g.filter = 'saturate(0.2) brightness(0.72) contrast(0.92)'; g.drawImage(after, 0, 0); g.filter = 'none';` then, only if `HAZE` (true) is true, `g.fillStyle = 'rgba(78,96,124,.28)'; g.fillRect(…)`. If `!('filter' in g)`, draw unfiltered and desaturate per pixel (`l = .299r + .587g + .114b`, mix 20% colour, ×0.72).
3. State object `state = { p: 0.5 }`. `render(fromDrag)`: `top.style.clipPath = inset(0 ${(1-p)*100}% 0 0)`; unless `fromDrag`, `gsap.set(handle, { x: p * frame.clientWidth - 1 })` and `draggable.update()`; `labelBefore.style.opacity = clamp(0, 1, (p - 0.08) / 0.2)`, `labelAfter.style.opacity = clamp(0, 1, ((1-p) - 0.08) / 0.2)`; update `aria-valuenow` (0–100) and `aria-valuetext`.
4. Draggable:
```js
Draggable.create(handle, { type: 'x', bounds: frame, cursor: 'ew-resize', zIndexBoost: false,
onPress() { gsap.killTweensOf(state); frame.classList.add('is-dragging'); },
onDrag() { state.p = clamp(0, 1, (this.x + 1) / frame.clientWidth); render(true); },
onRelease() { frame.classList.remove('is-dragging'); } });
```
No inertia.
5. Intro (not under reduced motion): `gsap.from('.ba__head > *', { y: 24, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08 })`, `gsap.from(frame, { y: 30, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`, then `gsap.timeline({ delay: 0.3 }).to(state, { p: 0.6, duration: 0.8, ease: 'power3.inOut', onUpdate: render }).to(state, { p: 0.5, duration: 0.8, ease: 'power3.inOut', onUpdate: render })` — 2 × 0.8s total sweep.
6. `animateTo(p, duration = 0.6, ease = 'power3.out')`: `gsap.to(state, { p: clamp(0,1,p), duration, ease, overwrite: true, onUpdate: render })`; instant `render()` under reduced motion.
**Interaction:** Drag the handle (pointer or touch; `touch-action: none` on the handle, `pan-y` on the frame so vertical page scroll still works). Click on the frame (outside the handle) → `animateTo((clientX − rect.left) / rect.width)` in 0.6s. Keyboard on the focused handle: ArrowLeft/Down −2%, ArrowRight/Up +2%, PageDown −10%, PageUp +10%, Home 0%, End 100%, each `animateTo(…, 0.25, 'power2.out')`. Knob hover 1.08×, drag 0.94×. Window resize re-renders so the handle stays at the same percentage.
**Responsive behavior:** The frame is fluid (`width: 100%; aspect-ratio: 16/10`), so at 375px it is ~343×214px; the header collapses to one column, the knob shrinks to 40px. The canvases keep their 1600×1000 backing store and are scaled by CSS. No `gsap.matchMedia()` branch is needed — Draggable bounds and `p` are relative.
**Accessibility:** The handle is `role="slider"` with `aria-valuemin/max/now`, `aria-valuetext` ("62% before, 38% after"), `aria-orientation="horizontal"` and a visible focus ring; it is fully keyboard operable. Labels and canvases are `aria-hidden` (the caption carries the description). With `prefers-reduced-motion: reduce`: no intro tweens and no auto-sweep; click-to-jump and keyboard moves apply instantly; the knob scale transition is disabled. Dragging still works because it is user-driven.
**Performance rules:** One `clip-path` style write and one transform per drag frame; no canvas redraw after the initial paint (the two canvases are painted once, ~1600×1000). `will-change: clip-path` on the top layer and `will-change: transform` on the handle. Everything is created inside `gsap.context()` (Draggable included) and `ctx.revert()` on unmount; the resize listener is removed by the context's cleanup function.
**Customization knobs:**
- `START` (0.5), `SWEEP_TO` (0.6) and the sweep leg duration (`SWEEP` = 0.8, played twice).
- `KEY_STEP` (0.02).
- Click-to-jump duration/ease (0.6, `power3.out`).
- The "before" filter string (`saturate(0.2) brightness(0.72) contrast(0.92)`) and the cool haze (`HAZE` = true; colour `rgba(78,96,124,.28)`).
- Handle colour `--accent` (#d4ff3a); knob size (44px).
- Swap the canvases for two `<img>` elements with the same aspect ratio — the clip logic is unchanged.
**Acceptance criteria:**
- [ ] Both layers are pixel-aligned; the split line is exactly at the handle on every frame while dragging.
- [ ] Handle cannot leave the frame (0%–100%); labels fade near the edges.
- [ ] On load the split sweeps 0.5 → 0.6 → 0.5 over 2 × 0.8s, then rests at 0.5.
- [ ] Click anywhere jumps the split there in 0.6s; arrow keys move 2% and update `aria-valuenow`.
- [ ] After a programmatic move, the next drag starts from the current position (no jump).
- [ ] Reduced motion: no sweep, instant jumps; no console errors; works at 375px.
---
**Reference implementation (use these exact values and class names):**
```html
<section class="ba">
<header class="ba__head">
<p class="ba__eyebrow">Layout · Draggable + clip-path</p>
<h1 class="ba__title">The grade, <em>before and after</em></h1>
<p class="ba__lead">Drag the handle, click anywhere in the frame, or use the arrow keys. The flat log-style "before" is derived from the graded frame on canvas, so both halves are always pixel-aligned.</p>
</header>
<div class="ba__frame">
<canvas class="ba__canvas ba__canvas--after" width="1600" height="1000" aria-hidden="true"></canvas>
<div class="ba__top">
<canvas class="ba__canvas ba__canvas--before" width="1600" height="1000" aria-hidden="true"></canvas>
</div>
<span class="ba__label ba__label--before" aria-hidden="true">Before</span>
<span class="ba__label ba__label--after" aria-hidden="true">After</span>
<div class="ba__handle" role="slider" tabindex="0" aria-label="Comparison position" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" aria-valuetext="50% before, 50% after" aria-orientation="horizontal">
<span class="ba__knob" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 7l-5 5 5 5M15 7l5 5-5 5"/></svg>
</span>
</div>
</div>
<p class="ba__caption"><span class="ba__caption-num">01</span> Kalk Bay, 06:41 — procedural frame, 1600 × 1000 · grade: warm shadows, lifted highlights</p>
</section>
```
```css
.ba { min-height: 100vh; max-width: 1400px; margin-inline: auto; padding: clamp(2rem, 6vw, 5rem) clamp(1rem, 5vw, 4rem) clamp(3rem, 6vw, 5rem); display: grid; align-content: center; gap: clamp(1.5rem, 3vw, 2.5rem); }
.ba__head { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1rem 3rem; align-items: end; }
.ba__eyebrow { grid-column: 1 / -1; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.ba__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.25rem, 5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95; }
.ba__title em { font-style: italic; color: var(--muted); }
.ba__lead { max-width: 46ch; color: var(--muted); font-size: clamp(.9rem, 1.05vw, 1.05rem); line-height: 1.55; justify-self: end; }
.ba__frame { position: relative; width: min(100%, calc((100vh - 16rem) * 1.6)); margin-inline: auto; aspect-ratio: 16 / 10; border-radius: 14px; overflow: hidden; background: var(--surface); border: 1px solid var(--line); user-select: none; -webkit-user-select: none; touch-action: pan-y; cursor: ew-resize; }
.ba__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ba__top { position: absolute; inset: 0; clip-path: inset(0 50% 0 0); will-change: clip-path; }
.ba__label { position: absolute; top: clamp(.75rem, 2vw, 1.25rem); font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .14em; padding: .45em .8em; border-radius: 999px; color: #fff; background: rgba(0,0,0,.42); backdrop-filter: blur(6px); pointer-events: none; }
.ba__label--before { left: clamp(.75rem, 2vw, 1.25rem); }
.ba__label--after { right: clamp(.75rem, 2vw, 1.25rem); }
.ba__handle { position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: var(--knob-accent, var(--accent)); cursor: ew-resize; touch-action: none; will-change: transform; }
.ba__handle::before { content: ''; position: absolute; inset: 0 -22px; }
.ba__handle:focus { outline: none; }
.ba__handle:focus-visible .ba__knob { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--knob-accent, var(--accent)); }
.ba__knob { position: absolute; left: 50%; top: 50%; width: 44px; height: 44px; transform: translate(-50%, -50%); display: grid; place-items: center; border-radius: 50%; background: var(--knob-accent, var(--accent)); color: #0a0a0a; box-shadow: 0 6px 24px rgba(0,0,0,.35); transition: transform .3s cubic-bezier(.2,.7,.1,1); }
.ba__frame:hover .ba__knob, .ba__handle:focus-visible .ba__knob { transform: translate(-50%, -50%) scale(1.08); }
.ba__frame.is-dragging .ba__knob { transform: translate(-50%, -50%) scale(.94); }
.ba__caption { font-family: var(--font-mono); font-size: 11px; letter-spacing: .06em; color: var(--muted); }
.ba__caption-num { color: var(--knob-accent, var(--accent)); margin-right: .75em; }
@media (max-width: 767px) {
.ba__head { grid-template-columns: 1fr; align-items: start; }
.ba__lead { justify-self: start; }
.ba__knob { width: 40px; height: 40px; }
}
@media (prefers-reduced-motion: reduce) {
.ba__knob { transition: none; }
}
```
```js
// Before / After Drag Slider — one procedural scene, a canvas-filtered twin, a Draggable clip handle.
gsap.registerPlugin(Draggable);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { start: 0.5, sweepTo: 0.6, sweepDuration: 0.8, jumpDuration: 0.6, accent: '#d4ff3a', haze: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const KEY_STEP = 0.02; // arrow-key step
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.ba');
const frame = document.querySelector('.ba__frame');
const topLayer = document.querySelector('.ba__top');
const handle = document.querySelector('.ba__handle');
const afterCanvas = document.querySelector('.ba__canvas--after');
const beforeCanvas = document.querySelector('.ba__canvas--before');
const labelBefore = document.querySelector('.ba__label--before');
const labelAfter = document.querySelector('.ba__label--after');
/* ---------- 1. Paint the scene once ---------- */
function mulberry32(seed) {
return () => { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
function paintAfter(c) {
const W = c.width, H = c.height, g = c.getContext('2d'), rnd = mulberry32(7);
const sunX = W * 0.62, sunY = H * 0.585, seaTop = H * 0.70;
const sky = g.createLinearGradient(0, 0, 0, seaTop);
sky.addColorStop(0, '#171231'); sky.addColorStop(0.42, '#5d2e4c'); sky.addColorStop(0.78, '#d9704a'); sky.addColorStop(1, '#f6c58d');
g.fillStyle = sky; g.fillRect(0, 0, W, H);
const glow = g.createRadialGradient(sunX, sunY, 0, sunX, sunY, W * 0.38);
glow.addColorStop(0, 'rgba(255,220,160,.95)'); glow.addColorStop(0.22, 'rgba(255,170,100,.4)'); glow.addColorStop(1, 'rgba(255,140,80,0)');
g.fillStyle = glow; g.fillRect(0, 0, W, H);
g.fillStyle = '#ffe6b8'; g.beginPath(); g.arc(sunX, sunY, H * 0.052, 0, Math.PI * 2); g.fill();
// Distant ridges — three layered silhouettes built from summed sines.
const ridges = [
{ y: 0.60, amp: 0.065, f: 1.7, col: '#4a3353' },
{ y: 0.645, amp: 0.045, f: 2.6, col: '#2e2238' },
{ y: 0.685, amp: 0.028, f: 4.1, col: '#1c1626' },
];
ridges.forEach((r, i) => {
g.fillStyle = r.col; g.beginPath(); g.moveTo(0, H);
for (let x = 0; x <= W; x += 6) {
const t = x / W;
const wave = 0.55 * Math.sin(t * Math.PI * r.f + i) + 0.3 * Math.sin(t * Math.PI * r.f * 2.3 + i * 2) + 0.15 * Math.sin(t * Math.PI * r.f * 5.1 + i * 3);
g.lineTo(x, H * (r.y - r.amp * wave));
}
g.lineTo(W, H); g.closePath(); g.fill();
});
// Sea
const sea = g.createLinearGradient(0, seaTop, 0, H);
sea.addColorStop(0, '#3a2a46'); sea.addColorStop(0.45, '#17132a'); sea.addColorStop(1, '#0a0912');
g.fillStyle = sea; g.fillRect(0, seaTop, W, H - seaTop);
// Sun path on the water — many thin streaks that widen toward the camera.
for (let i = 0; i < 90; i++) {
const v = rnd(); const y = seaTop + v * v * (H - seaTop) * 0.9;
const w = (0.06 + rnd() * 0.3) * W * (0.25 + v * 0.75);
g.fillStyle = `rgba(255,${170 + Math.round(rnd() * 50)},120,${0.05 + rnd() * 0.22})`;
g.fillRect(sunX - w / 2 + (rnd() - 0.5) * W * 0.06, y, w, 1 + rnd() * 2.5);
}
// Foreground rocks
g.fillStyle = '#07060a';
g.beginPath(); g.moveTo(0, H); g.lineTo(0, H * 0.78);
for (let x = 0; x <= W * 0.42; x += 10) g.lineTo(x, H * (0.80 + 0.07 * Math.sin(x / W * 11) + 0.04 * Math.sin(x / W * 29)) + (x / (W * 0.42)) * H * 0.14);
g.lineTo(W * 0.42, H); g.closePath(); g.fill();
g.beginPath(); g.moveTo(W, H); g.lineTo(W, H * 0.86);
for (let x = W; x >= W * 0.7; x -= 10) g.lineTo(x, H * (0.88 + 0.05 * Math.sin(x / W * 17)) + ((W - x) / (W * 0.3)) * H * 0.1);
g.lineTo(W * 0.7, H); g.closePath(); g.fill();
// Vignette + grain
const vig = g.createRadialGradient(W / 2, H / 2, H * 0.3, W / 2, H / 2, W * 0.75);
vig.addColorStop(0, 'rgba(0,0,0,0)'); vig.addColorStop(1, 'rgba(0,0,0,.45)');
g.fillStyle = vig; g.fillRect(0, 0, W, H);
const tile = document.createElement('canvas'); tile.width = tile.height = 128;
const tg = tile.getContext('2d'); const img = tg.createImageData(128, 128);
for (let i = 0; i < img.data.length; i += 4) { const n = 200 + rnd() * 55; img.data[i] = img.data[i + 1] = img.data[i + 2] = n; img.data[i + 3] = 26; }
tg.putImageData(img, 0, 0);
g.globalCompositeOperation = 'overlay'; g.fillStyle = g.createPattern(tile, 'repeat'); g.fillRect(0, 0, W, H);
g.globalCompositeOperation = 'source-over';
}
/* ---------- 2. Derive the "before" from the "after" ---------- */
function deriveBefore(src, dst, haze = true) {
const g = dst.getContext('2d');
g.clearRect(0, 0, dst.width, dst.height);
if ('filter' in g) {
g.filter = 'saturate(0.2) brightness(0.72) contrast(0.92)';
g.drawImage(src, 0, 0);
g.filter = 'none';
} else {
g.drawImage(src, 0, 0);
const img = g.getImageData(0, 0, dst.width, dst.height), d = img.data;
for (let i = 0; i < d.length; i += 4) {
const l = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
d[i] = (l + (d[i] - l) * 0.2) * 0.72; d[i + 1] = (l + (d[i + 1] - l) * 0.2) * 0.72; d[i + 2] = (l + (d[i + 2] - l) * 0.2) * 0.72;
}
g.putImageData(img, 0, 0);
}
if (haze) { g.fillStyle = 'rgba(78,96,124,.28)'; g.fillRect(0, 0, dst.width, dst.height); } // flat, cool log haze
}
paintAfter(afterCanvas); // the graded scene is painted once; the "before" is re-derived per build (cheap)
/* ---------- 3. Position state ---------- */
const state = { p: DEFAULTS.start };
let drag;
let JUMP_DURATION = DEFAULTS.jumpDuration;
const width = () => frame.clientWidth;
function render(fromDrag = false) {
const p = state.p;
topLayer.style.clipPath = `inset(0 ${(1 - p) * 100}% 0 0)`;
if (!fromDrag) { gsap.set(handle, { x: p * width() - 1 }); drag?.update(); }
labelBefore.style.opacity = gsap.utils.clamp(0, 1, (p - 0.08) / 0.2);
labelAfter.style.opacity = gsap.utils.clamp(0, 1, ((1 - p) - 0.08) / 0.2);
const pct = Math.round(p * 100);
handle.setAttribute('aria-valuenow', pct);
handle.setAttribute('aria-valuetext', `${pct}% before, ${100 - pct}% after`);
}
function animateTo(p, duration = JUMP_DURATION, ease = 'power3.out') {
p = gsap.utils.clamp(0, 1, p);
if (reduced) { state.p = p; render(); return; }
gsap.to(state, { p, duration, ease, overwrite: true, onUpdate: () => render() });
}
let ctx;
function build() {
ctx?.revert();
gsap.killTweensOf(state);
section.style.setProperty('--knob-accent', K('accent'));
const START = K('start'); // initial split
const SWEEP_TO = K('sweepTo'); // intro sweep target
const SWEEP = K('sweepDuration'); // per leg
JUMP_DURATION = K('jumpDuration'); // click-to-jump
deriveBefore(afterCanvas, beforeCanvas, K('haze'));
state.p = START;
ctx = gsap.context(() => {
const ac = new AbortController();
render();
drag = Draggable.create(handle, {
type: 'x', bounds: frame, cursor: 'ew-resize', zIndexBoost: false,
onPress() { gsap.killTweensOf(state); frame.classList.add('is-dragging'); },
onDrag() { state.p = gsap.utils.clamp(0, 1, (this.x + 1) / width()); render(true); },
onRelease() { frame.classList.remove('is-dragging'); },
})[0];
// Click (not a drag on the handle) jumps the split to the pointer.
frame.addEventListener('click', (e) => {
if (handle.contains(e.target)) return;
const r = frame.getBoundingClientRect();
animateTo((e.clientX - r.left) / r.width);
}, { signal: ac.signal });
handle.addEventListener('keydown', (e) => {
const map = { ArrowLeft: state.p - KEY_STEP, ArrowRight: state.p + KEY_STEP, ArrowDown: state.p - KEY_STEP, ArrowUp: state.p + KEY_STEP, PageDown: state.p - 0.1, PageUp: state.p + 0.1, Home: 0, End: 1 };
if (!(e.key in map)) return;
e.preventDefault();
animateTo(map[e.key], 0.25, 'power2.out');
}, { signal: ac.signal });
window.addEventListener('resize', () => render(), { signal: ac.signal });
if (!reduced) {
gsap.from('.ba__head > *', { y: 24, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08 });
gsap.from(frame, { y: 30, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
// Intro sweep: START → SWEEP_TO → START, 2 × SWEEP seconds, teaches the gesture.
gsap.timeline({ delay: 0.3 })
.to(state, { p: SWEEP_TO, duration: SWEEP, ease: 'power3.inOut', onUpdate: () => render() })
.to(state, { p: START, duration: SWEEP, ease: 'power3.inOut', onUpdate: () => render() });
}
return () => {
ac.abort(); // listeners go with the context so rebuilds never stack handlers
drag?.kill(); drag = null; // Draggable is re-created per build
frame.classList.remove('is-dragging');
};
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (Framer & Webflow notes): Before / After Drag Slider
**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 withBeforeAfterDragSlider(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.
- 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 `ba`), 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/Draggable.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 timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- 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: Before / After Drag Slider
**What it does (visual):** A dark page with a heading ("The grade, before and after") and a short lead, then a wide 16:10 rounded frame. Inside is a dusk seascape — a plum-to-apricot sky, a low sun with a warm glow, three layered mountain ridges, a dark sea carrying a streaked sun path, black foreground rocks, a vignette and fine grain. The frame is split by a 2px vertical line in the accent colour (#d4ff3a) with a 44px circle of the same colour in the middle holding two small chevrons. Left of the line the same picture is flat and desaturated, with a cool blue-grey haze when `HAZE` is true (the ungraded "before"); right of the line it is the warm graded "after". Two frosted pill labels sit in the top corners: "Before" (left) and "After" (right). On load the heading fades up, the frame rises 30px, and 0.3s later the handle glides from the resting split (0.5 of the width) to 0.6 and back over 2 × 0.8s, revealing more of one side then settling — a silent hint that it can be dragged. Dragging the circle moves the split in real time; the "Before" label fades out as the handle nears the left edge and "After" fades as it nears the right. Clicking anywhere in the frame slides the handle there in 0.6s. The knob scales to 108% on hover and 94% while dragging. With the handle focused, arrow keys nudge it 2% per press.
**Tech stack:**
- GSAP 3.13+ with the Draggable plugin (free since 3.13).
- 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/Draggable.min.js"></script>`
- npm: `import gsap from "gsap"; import { Draggable } from "gsap/Draggable"; gsap.registerPlugin(Draggable);`
- Canvas 2D for the procedural scene and for deriving the "before" (`ctx.filter`, with a per-pixel fallback). In production use two real `<img>`s with identical dimensions instead of the canvases.
**HTML structure:**
```html
<section class="ba">
<header class="ba__head">
<p class="ba__eyebrow">Layout · Draggable + clip-path</p>
<h1 class="ba__title">The grade, <em>before and after</em></h1>
<p class="ba__lead">Drag the handle, click anywhere in the frame, or use the arrow keys.</p>
</header>
<div class="ba__frame">
<canvas class="ba__canvas ba__canvas--after" width="1600" height="1000" aria-hidden="true"></canvas>
<div class="ba__top"><canvas class="ba__canvas ba__canvas--before" width="1600" height="1000" aria-hidden="true"></canvas></div>
<span class="ba__label ba__label--before" aria-hidden="true">Before</span>
<span class="ba__label ba__label--after" aria-hidden="true">After</span>
<div class="ba__handle" role="slider" tabindex="0" aria-label="Comparison position" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" aria-valuetext="50% before, 50% after" aria-orientation="horizontal">
<span class="ba__knob" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 7l-5 5 5 5M15 7l5 5-5 5"/></svg></span>
</div>
</div>
<p class="ba__caption"><span class="ba__caption-num">01</span> Kalk Bay, 06:41 — procedural frame</p>
</section>
```
The "before" layer is on top and is clipped; the "after" layer underneath is always fully painted.
**CSS requirements:**
- `.ba`: `min-height: 100vh; max-width: 1400px; margin-inline: auto; padding: clamp(2rem, 6vw, 5rem) clamp(1rem, 5vw, 4rem); display: grid; align-content: center; gap: clamp(1.5rem, 3vw, 2.5rem);`
- `.ba__head`: two-column grid (`minmax(0,1fr) minmax(0,1fr)`, `align-items: end`); eyebrow spans both columns; `.ba__title` display serif 300, `font-size: clamp(2.25rem, 5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95` with an italic muted `<em>`; `.ba__lead` muted, `max-width: 46ch; justify-self: end`.
- `.ba__frame`: `position: relative; width: min(100%, calc((100vh - 16rem) * 1.6)); margin-inline: auto; aspect-ratio: 16 / 10; border-radius: 14px; overflow: hidden; background: var(--surface); border: 1px solid var(--line); user-select: none; touch-action: pan-y; cursor: ew-resize;`
- `.ba__canvas`: `position: absolute; inset: 0; width: 100%; height: 100%;`
- `.ba__top`: `position: absolute; inset: 0; clip-path: inset(0 50% 0 0); will-change: clip-path;` — JS writes `inset(0 ${(1 - p) * 100}% 0 0)`.
- `.ba__label`: `position: absolute; top: clamp(.75rem, 2vw, 1.25rem); font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .14em; padding: .45em .8em; border-radius: 999px; color: #fff; background: rgba(0,0,0,.42); backdrop-filter: blur(6px); pointer-events: none;` `--before { left: … }`, `--after { right: … }`.
- `.ba__handle`: `position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: var(--accent); cursor: ew-resize; touch-action: none; will-change: transform;` `::before { content: ''; position: absolute; inset: 0 -22px; }` widens the hit area to 46px.
- `.ba__knob`: `position: absolute; left: 50%; top: 50%; width: 44px; height: 44px; transform: translate(-50%, -50%); display: grid; place-items: center; border-radius: 50%; background: var(--accent); color: #0a0a0a; box-shadow: 0 6px 24px rgba(0,0,0,.35); transition: transform .3s cubic-bezier(.2,.7,.1,1);` hover/focus-visible → `scale(1.08)`, `.is-dragging` → `scale(.94)`. Focus ring: `box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent)` on the knob.
- `.ba__caption`: mono 11px muted; the index is `var(--accent)`.
- Accent colour (handle line, knob, caption index, focus ring): `--accent: #d4ff3a`.
**Animation spec (exact values):**
1. Paint the scene on the 1600×1000 "after" canvas (seeded `mulberry32(7)` RNG so it is deterministic): sky `linearGradient(0→H·0.70)` stops `#171231 @0, #5d2e4c @.42, #d9704a @.78, #f6c58d @1`; sun glow `radialGradient` at `(0.62W, 0.585H)` radius `0.38W` from `rgba(255,220,160,.95)` → `rgba(255,170,100,.4) @.22` → transparent; sun disc radius `0.052H` `#ffe6b8`; three ridges as filled polylines `y = H·(base − amp·wave)` with `wave = .55·sin(πft+i) + .3·sin(2.3πft+2i) + .15·sin(5.1πft+3i)` for `{base .60, amp .065, f 1.7, #4a3353}`, `{.645, .045, 2.6, #2e2238}`, `{.685, .028, 4.1, #1c1626}`; sea gradient from `0.70H` `#3a2a46 → #17132a @.45 → #0a0912`; 90 sun-path streaks (1–3.5px tall, widening toward the bottom, `rgba(255,170–220,120,.05–.27)`) centred on the sun x; two black (`#07060a`) foreground rock silhouettes bottom-left/right; radial vignette to `rgba(0,0,0,.45)`; a 128px grain tile (alpha 26) composited with `overlay`.
2. Derive the "before": `g.filter = 'saturate(0.2) brightness(0.72) contrast(0.92)'; g.drawImage(after, 0, 0); g.filter = 'none';` then, only if `HAZE` (true) is true, `g.fillStyle = 'rgba(78,96,124,.28)'; g.fillRect(…)`. If `!('filter' in g)`, draw unfiltered and desaturate per pixel (`l = .299r + .587g + .114b`, mix 20% colour, ×0.72).
3. State object `state = { p: 0.5 }`. `render(fromDrag)`: `top.style.clipPath = inset(0 ${(1-p)*100}% 0 0)`; unless `fromDrag`, `gsap.set(handle, { x: p * frame.clientWidth - 1 })` and `draggable.update()`; `labelBefore.style.opacity = clamp(0, 1, (p - 0.08) / 0.2)`, `labelAfter.style.opacity = clamp(0, 1, ((1-p) - 0.08) / 0.2)`; update `aria-valuenow` (0–100) and `aria-valuetext`.
4. Draggable:
```js
Draggable.create(handle, { type: 'x', bounds: frame, cursor: 'ew-resize', zIndexBoost: false,
onPress() { gsap.killTweensOf(state); frame.classList.add('is-dragging'); },
onDrag() { state.p = clamp(0, 1, (this.x + 1) / frame.clientWidth); render(true); },
onRelease() { frame.classList.remove('is-dragging'); } });
```
No inertia.
5. Intro (not under reduced motion): `gsap.from('.ba__head > *', { y: 24, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08 })`, `gsap.from(frame, { y: 30, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`, then `gsap.timeline({ delay: 0.3 }).to(state, { p: 0.6, duration: 0.8, ease: 'power3.inOut', onUpdate: render }).to(state, { p: 0.5, duration: 0.8, ease: 'power3.inOut', onUpdate: render })` — 2 × 0.8s total sweep.
6. `animateTo(p, duration = 0.6, ease = 'power3.out')`: `gsap.to(state, { p: clamp(0,1,p), duration, ease, overwrite: true, onUpdate: render })`; instant `render()` under reduced motion.
**Interaction:** Drag the handle (pointer or touch; `touch-action: none` on the handle, `pan-y` on the frame so vertical page scroll still works). Click on the frame (outside the handle) → `animateTo((clientX − rect.left) / rect.width)` in 0.6s. Keyboard on the focused handle: ArrowLeft/Down −2%, ArrowRight/Up +2%, PageDown −10%, PageUp +10%, Home 0%, End 100%, each `animateTo(…, 0.25, 'power2.out')`. Knob hover 1.08×, drag 0.94×. Window resize re-renders so the handle stays at the same percentage.
**Responsive behavior:** The frame is fluid (`width: 100%; aspect-ratio: 16/10`), so at 375px it is ~343×214px; the header collapses to one column, the knob shrinks to 40px. The canvases keep their 1600×1000 backing store and are scaled by CSS. No `gsap.matchMedia()` branch is needed — Draggable bounds and `p` are relative.
**Accessibility:** The handle is `role="slider"` with `aria-valuemin/max/now`, `aria-valuetext` ("62% before, 38% after"), `aria-orientation="horizontal"` and a visible focus ring; it is fully keyboard operable. Labels and canvases are `aria-hidden` (the caption carries the description). With `prefers-reduced-motion: reduce`: no intro tweens and no auto-sweep; click-to-jump and keyboard moves apply instantly; the knob scale transition is disabled. Dragging still works because it is user-driven.
**Performance rules:** One `clip-path` style write and one transform per drag frame; no canvas redraw after the initial paint (the two canvases are painted once, ~1600×1000). `will-change: clip-path` on the top layer and `will-change: transform` on the handle. Everything is created inside `gsap.context()` (Draggable included) and `ctx.revert()` on unmount; the resize listener is removed by the context's cleanup function.
**Customization knobs:**
- `START` (0.5), `SWEEP_TO` (0.6) and the sweep leg duration (`SWEEP` = 0.8, played twice).
- `KEY_STEP` (0.02).
- Click-to-jump duration/ease (0.6, `power3.out`).
- The "before" filter string (`saturate(0.2) brightness(0.72) contrast(0.92)`) and the cool haze (`HAZE` = true; colour `rgba(78,96,124,.28)`).
- Handle colour `--accent` (#d4ff3a); knob size (44px).
- Swap the canvases for two `<img>` elements with the same aspect ratio — the clip logic is unchanged.
**Acceptance criteria:**
- [ ] Both layers are pixel-aligned; the split line is exactly at the handle on every frame while dragging.
- [ ] Handle cannot leave the frame (0%–100%); labels fade near the edges.
- [ ] On load the split sweeps 0.5 → 0.6 → 0.5 over 2 × 0.8s, then rests at 0.5.
- [ ] Click anywhere jumps the split there in 0.6s; arrow keys move 2% and update `aria-valuenow`.
- [ ] After a programmatic move, the next drag starts from the current position (no jump).
- [ ] Reduced motion: no sweep, instant jumps; no console errors; works at 375px.
Source
Full source code
<section class="ba">
<header class="ba__head">
<p class="ba__eyebrow">Layout · Draggable + clip-path</p>
<h1 class="ba__title">The grade, <em>before and after</em></h1>
<p class="ba__lead">Drag the handle, click anywhere in the frame, or use the arrow keys. The flat log-style "before" is derived from the graded frame on canvas, so both halves are always pixel-aligned.</p>
</header>
<div class="ba__frame">
<canvas class="ba__canvas ba__canvas--after" width="1600" height="1000" aria-hidden="true"></canvas>
<div class="ba__top">
<canvas class="ba__canvas ba__canvas--before" width="1600" height="1000" aria-hidden="true"></canvas>
</div>
<span class="ba__label ba__label--before" aria-hidden="true">Before</span>
<span class="ba__label ba__label--after" aria-hidden="true">After</span>
<div class="ba__handle" role="slider" tabindex="0" aria-label="Comparison position" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" aria-valuetext="50% before, 50% after" aria-orientation="horizontal">
<span class="ba__knob" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 7l-5 5 5 5M15 7l5 5-5 5"/></svg>
</span>
</div>
</div>
<p class="ba__caption"><span class="ba__caption-num">01</span> Kalk Bay, 06:41 — procedural frame, 1600 × 1000 · grade: warm shadows, lifted highlights</p>
</section>
.ba { min-height: 100vh; max-width: 1400px; margin-inline: auto; padding: clamp(2rem, 6vw, 5rem) clamp(1rem, 5vw, 4rem) clamp(3rem, 6vw, 5rem); display: grid; align-content: center; gap: clamp(1.5rem, 3vw, 2.5rem); }
.ba__head { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1rem 3rem; align-items: end; }
.ba__eyebrow { grid-column: 1 / -1; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.ba__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.25rem, 5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95; }
.ba__title em { font-style: italic; color: var(--muted); }
.ba__lead { max-width: 46ch; color: var(--muted); font-size: clamp(.9rem, 1.05vw, 1.05rem); line-height: 1.55; justify-self: end; }
.ba__frame { position: relative; width: min(100%, calc((100vh - 16rem) * 1.6)); margin-inline: auto; aspect-ratio: 16 / 10; border-radius: 14px; overflow: hidden; background: var(--surface); border: 1px solid var(--line); user-select: none; -webkit-user-select: none; touch-action: pan-y; cursor: ew-resize; }
.ba__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ba__top { position: absolute; inset: 0; clip-path: inset(0 50% 0 0); will-change: clip-path; }
.ba__label { position: absolute; top: clamp(.75rem, 2vw, 1.25rem); font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .14em; padding: .45em .8em; border-radius: 999px; color: #fff; background: rgba(0,0,0,.42); backdrop-filter: blur(6px); pointer-events: none; }
.ba__label--before { left: clamp(.75rem, 2vw, 1.25rem); }
.ba__label--after { right: clamp(.75rem, 2vw, 1.25rem); }
.ba__handle { position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: var(--knob-accent, var(--accent)); cursor: ew-resize; touch-action: none; will-change: transform; }
.ba__handle::before { content: ''; position: absolute; inset: 0 -22px; }
.ba__handle:focus { outline: none; }
.ba__handle:focus-visible .ba__knob { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--knob-accent, var(--accent)); }
.ba__knob { position: absolute; left: 50%; top: 50%; width: 44px; height: 44px; transform: translate(-50%, -50%); display: grid; place-items: center; border-radius: 50%; background: var(--knob-accent, var(--accent)); color: #0a0a0a; box-shadow: 0 6px 24px rgba(0,0,0,.35); transition: transform .3s cubic-bezier(.2,.7,.1,1); }
.ba__frame:hover .ba__knob, .ba__handle:focus-visible .ba__knob { transform: translate(-50%, -50%) scale(1.08); }
.ba__frame.is-dragging .ba__knob { transform: translate(-50%, -50%) scale(.94); }
.ba__caption { font-family: var(--font-mono); font-size: 11px; letter-spacing: .06em; color: var(--muted); }
.ba__caption-num { color: var(--knob-accent, var(--accent)); margin-right: .75em; }
@media (max-width: 767px) {
.ba__head { grid-template-columns: 1fr; align-items: start; }
.ba__lead { justify-self: start; }
.ba__knob { width: 40px; height: 40px; }
}
@media (prefers-reduced-motion: reduce) {
.ba__knob { transition: none; }
}
// Before / After Drag Slider — one procedural scene, a canvas-filtered twin, a Draggable clip handle.
gsap.registerPlugin(Draggable);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { start: 0.5, sweepTo: 0.6, sweepDuration: 0.8, jumpDuration: 0.6, accent: '#d4ff3a', haze: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const KEY_STEP = 0.02; // arrow-key step
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.ba');
const frame = document.querySelector('.ba__frame');
const topLayer = document.querySelector('.ba__top');
const handle = document.querySelector('.ba__handle');
const afterCanvas = document.querySelector('.ba__canvas--after');
const beforeCanvas = document.querySelector('.ba__canvas--before');
const labelBefore = document.querySelector('.ba__label--before');
const labelAfter = document.querySelector('.ba__label--after');
/* ---------- 1. Paint the scene once ---------- */
function mulberry32(seed) {
return () => { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
function paintAfter(c) {
const W = c.width, H = c.height, g = c.getContext('2d'), rnd = mulberry32(7);
const sunX = W * 0.62, sunY = H * 0.585, seaTop = H * 0.70;
const sky = g.createLinearGradient(0, 0, 0, seaTop);
sky.addColorStop(0, '#171231'); sky.addColorStop(0.42, '#5d2e4c'); sky.addColorStop(0.78, '#d9704a'); sky.addColorStop(1, '#f6c58d');
g.fillStyle = sky; g.fillRect(0, 0, W, H);
const glow = g.createRadialGradient(sunX, sunY, 0, sunX, sunY, W * 0.38);
glow.addColorStop(0, 'rgba(255,220,160,.95)'); glow.addColorStop(0.22, 'rgba(255,170,100,.4)'); glow.addColorStop(1, 'rgba(255,140,80,0)');
g.fillStyle = glow; g.fillRect(0, 0, W, H);
g.fillStyle = '#ffe6b8'; g.beginPath(); g.arc(sunX, sunY, H * 0.052, 0, Math.PI * 2); g.fill();
// Distant ridges — three layered silhouettes built from summed sines.
const ridges = [
{ y: 0.60, amp: 0.065, f: 1.7, col: '#4a3353' },
{ y: 0.645, amp: 0.045, f: 2.6, col: '#2e2238' },
{ y: 0.685, amp: 0.028, f: 4.1, col: '#1c1626' },
];
ridges.forEach((r, i) => {
g.fillStyle = r.col; g.beginPath(); g.moveTo(0, H);
for (let x = 0; x <= W; x += 6) {
const t = x / W;
const wave = 0.55 * Math.sin(t * Math.PI * r.f + i) + 0.3 * Math.sin(t * Math.PI * r.f * 2.3 + i * 2) + 0.15 * Math.sin(t * Math.PI * r.f * 5.1 + i * 3);
g.lineTo(x, H * (r.y - r.amp * wave));
}
g.lineTo(W, H); g.closePath(); g.fill();
});
// Sea
const sea = g.createLinearGradient(0, seaTop, 0, H);
sea.addColorStop(0, '#3a2a46'); sea.addColorStop(0.45, '#17132a'); sea.addColorStop(1, '#0a0912');
g.fillStyle = sea; g.fillRect(0, seaTop, W, H - seaTop);
// Sun path on the water — many thin streaks that widen toward the camera.
for (let i = 0; i < 90; i++) {
const v = rnd(); const y = seaTop + v * v * (H - seaTop) * 0.9;
const w = (0.06 + rnd() * 0.3) * W * (0.25 + v * 0.75);
g.fillStyle = `rgba(255,${170 + Math.round(rnd() * 50)},120,${0.05 + rnd() * 0.22})`;
g.fillRect(sunX - w / 2 + (rnd() - 0.5) * W * 0.06, y, w, 1 + rnd() * 2.5);
}
// Foreground rocks
g.fillStyle = '#07060a';
g.beginPath(); g.moveTo(0, H); g.lineTo(0, H * 0.78);
for (let x = 0; x <= W * 0.42; x += 10) g.lineTo(x, H * (0.80 + 0.07 * Math.sin(x / W * 11) + 0.04 * Math.sin(x / W * 29)) + (x / (W * 0.42)) * H * 0.14);
g.lineTo(W * 0.42, H); g.closePath(); g.fill();
g.beginPath(); g.moveTo(W, H); g.lineTo(W, H * 0.86);
for (let x = W; x >= W * 0.7; x -= 10) g.lineTo(x, H * (0.88 + 0.05 * Math.sin(x / W * 17)) + ((W - x) / (W * 0.3)) * H * 0.1);
g.lineTo(W * 0.7, H); g.closePath(); g.fill();
// Vignette + grain
const vig = g.createRadialGradient(W / 2, H / 2, H * 0.3, W / 2, H / 2, W * 0.75);
vig.addColorStop(0, 'rgba(0,0,0,0)'); vig.addColorStop(1, 'rgba(0,0,0,.45)');
g.fillStyle = vig; g.fillRect(0, 0, W, H);
const tile = document.createElement('canvas'); tile.width = tile.height = 128;
const tg = tile.getContext('2d'); const img = tg.createImageData(128, 128);
for (let i = 0; i < img.data.length; i += 4) { const n = 200 + rnd() * 55; img.data[i] = img.data[i + 1] = img.data[i + 2] = n; img.data[i + 3] = 26; }
tg.putImageData(img, 0, 0);
g.globalCompositeOperation = 'overlay'; g.fillStyle = g.createPattern(tile, 'repeat'); g.fillRect(0, 0, W, H);
g.globalCompositeOperation = 'source-over';
}
/* ---------- 2. Derive the "before" from the "after" ---------- */
function deriveBefore(src, dst, haze = true) {
const g = dst.getContext('2d');
g.clearRect(0, 0, dst.width, dst.height);
if ('filter' in g) {
g.filter = 'saturate(0.2) brightness(0.72) contrast(0.92)';
g.drawImage(src, 0, 0);
g.filter = 'none';
} else {
g.drawImage(src, 0, 0);
const img = g.getImageData(0, 0, dst.width, dst.height), d = img.data;
for (let i = 0; i < d.length; i += 4) {
const l = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
d[i] = (l + (d[i] - l) * 0.2) * 0.72; d[i + 1] = (l + (d[i + 1] - l) * 0.2) * 0.72; d[i + 2] = (l + (d[i + 2] - l) * 0.2) * 0.72;
}
g.putImageData(img, 0, 0);
}
if (haze) { g.fillStyle = 'rgba(78,96,124,.28)'; g.fillRect(0, 0, dst.width, dst.height); } // flat, cool log haze
}
paintAfter(afterCanvas); // the graded scene is painted once; the "before" is re-derived per build (cheap)
/* ---------- 3. Position state ---------- */
const state = { p: DEFAULTS.start };
let drag;
let JUMP_DURATION = DEFAULTS.jumpDuration;
const width = () => frame.clientWidth;
function render(fromDrag = false) {
const p = state.p;
topLayer.style.clipPath = `inset(0 ${(1 - p) * 100}% 0 0)`;
if (!fromDrag) { gsap.set(handle, { x: p * width() - 1 }); drag?.update(); }
labelBefore.style.opacity = gsap.utils.clamp(0, 1, (p - 0.08) / 0.2);
labelAfter.style.opacity = gsap.utils.clamp(0, 1, ((1 - p) - 0.08) / 0.2);
const pct = Math.round(p * 100);
handle.setAttribute('aria-valuenow', pct);
handle.setAttribute('aria-valuetext', `${pct}% before, ${100 - pct}% after`);
}
function animateTo(p, duration = JUMP_DURATION, ease = 'power3.out') {
p = gsap.utils.clamp(0, 1, p);
if (reduced) { state.p = p; render(); return; }
gsap.to(state, { p, duration, ease, overwrite: true, onUpdate: () => render() });
}
let ctx;
function build() {
ctx?.revert();
gsap.killTweensOf(state);
section.style.setProperty('--knob-accent', K('accent'));
const START = K('start'); // initial split
const SWEEP_TO = K('sweepTo'); // intro sweep target
const SWEEP = K('sweepDuration'); // per leg
JUMP_DURATION = K('jumpDuration'); // click-to-jump
deriveBefore(afterCanvas, beforeCanvas, K('haze'));
state.p = START;
ctx = gsap.context(() => {
const ac = new AbortController();
render();
drag = Draggable.create(handle, {
type: 'x', bounds: frame, cursor: 'ew-resize', zIndexBoost: false,
onPress() { gsap.killTweensOf(state); frame.classList.add('is-dragging'); },
onDrag() { state.p = gsap.utils.clamp(0, 1, (this.x + 1) / width()); render(true); },
onRelease() { frame.classList.remove('is-dragging'); },
})[0];
// Click (not a drag on the handle) jumps the split to the pointer.
frame.addEventListener('click', (e) => {
if (handle.contains(e.target)) return;
const r = frame.getBoundingClientRect();
animateTo((e.clientX - r.left) / r.width);
}, { signal: ac.signal });
handle.addEventListener('keydown', (e) => {
const map = { ArrowLeft: state.p - KEY_STEP, ArrowRight: state.p + KEY_STEP, ArrowDown: state.p - KEY_STEP, ArrowUp: state.p + KEY_STEP, PageDown: state.p - 0.1, PageUp: state.p + 0.1, Home: 0, End: 1 };
if (!(e.key in map)) return;
e.preventDefault();
animateTo(map[e.key], 0.25, 'power2.out');
}, { signal: ac.signal });
window.addEventListener('resize', () => render(), { signal: ac.signal });
if (!reduced) {
gsap.from('.ba__head > *', { y: 24, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08 });
gsap.from(frame, { y: 30, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
// Intro sweep: START → SWEEP_TO → START, 2 × SWEEP seconds, teaches the gesture.
gsap.timeline({ delay: 0.3 })
.to(state, { p: SWEEP_TO, duration: SWEEP, ease: 'power3.inOut', onUpdate: () => render() })
.to(state, { p: START, duration: SWEEP, ease: 'power3.inOut', onUpdate: () => render() });
}
return () => {
ac.abort(); // listeners go with the context so rebuilds never stack handlers
drag?.kill(); drag = null; // Draggable is re-created per build
frame.classList.remove('is-dragging');
};
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());