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.

Loading

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.

prompt.md · Before / After Drag Slider
### 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>