Scroll Velocity Skew
A long grid of gradient tiles and oversized words shears up to ±8° and shrinks slightly in proportion to scroll velocity, then eases back to zero in 0.8s; a mono readout shows the live velocity and skew.
- Scroll
- gsap
- scrolltrigger
- intermediate
- light perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1204 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Scroll Velocity Skew
**What it does (visual):** A centred intro ("Momentum") leads into a long three-column grid: twelve portrait tiles painted as soft, grainy duotone gradients (each with a mono caption like "01 Harbour") interleaved with four full-width oversized serif words — "Velocity", "Inertia", "Drift", "Settle" — alternating left and right aligned. At rest everything is perfectly upright. When the user scrolls, every tile and word shears vertically (`skewY`) in proportion to scroll speed — scrolling down leans them one way, scrolling up the other — up to ±8°, while shrinking very slightly (down to 0.94) and the big words drift sideways up to 48px. The motion is smoothed, so a flick produces a lean that builds over a fraction of a second and, once the page stops, eases back to upright over 0.8s with a soft deceleration (`power3.out`). A small fixed mono readout pill in the bottom-right shows "velocity +1234 px/s", "skew −3.5°" and a 140px hairline bar that fills with the accent colour `#d4ff3a` as the skew approaches its clamp.
**Tech stack:**
- GSAP 3.13+ and ScrollTrigger (for `getVelocity()`), `gsap.quickTo`, `gsap.quickSetter`, `gsap.utils.clamp`.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);`
- Works with Lenis (`lenis.on('scroll', ScrollTrigger.update)`); velocity is read from ScrollTrigger either way.
**HTML structure:**
```html
<section class="vs-intro"><p class="vs-intro__label">Scroll · Velocity skew</p><h1 class="vs-intro__title">Momentum</h1></section>
<section class="vs">
<div class="vs__grid">
<h2 class="vs__item vs__word">Velocity</h2>
<article class="vs__item vs__tile" style="--h:196"><p class="vs__cap"><span>01</span>Harbour</p></article>
<article class="vs__item vs__tile" style="--h:28"><p class="vs__cap"><span>02</span>Kiln</p></article>
<article class="vs__item vs__tile" style="--h:262"><p class="vs__cap"><span>03</span>Meridian</p></article>
<h2 class="vs__item vs__word vs__word--right">Inertia</h2>
<!-- …3 tiles, word, 3 tiles, word, 3 tiles: 12 tiles + 4 words total -->
</div>
</section>
<section class="vs-outro"><p>Everything has returned to zero.</p></section>
<aside class="vs__readout" aria-live="off">
<span>velocity <b class="vs__read-v">+0000</b> px/s</span>
<span>skew <b class="vs__read-s">+0.0</b>°</span>
<span class="vs__readout-bar"><i class="vs__readout-fill"></i></span>
</aside>
```
(Tiles are CSS gradients driven by a `--h` hue so no image assets are needed; swap in `<img>`s in production.)
**CSS requirements:**
- `.vs-intro`, `.vs-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center;`
- `.vs`: `padding: 4vw 6vw 10vw;`
- `.vs__grid`: `display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(1rem, 2.5vw, 2.5rem); align-items: center;` → `repeat(2, 1fr)` under 768px.
- `.vs__item`: `will-change: transform; transform-origin: 50% 50%;` (centre origin so shearing doesn't look like a shift).
- `.vs__word`: `grid-column: 1 / -1;` display serif, `font-weight: 300; font-size: clamp(3.5rem, 13vw, 13rem); letter-spacing: -0.04em; line-height: .85; padding: .25em 0 .1em;` `.vs__word--right { text-align: right; font-style: italic; font-weight: 200; }`
- `.vs__tile`: `position: relative; aspect-ratio: 4 / 5; border-radius: 10px; overflow: hidden; isolation: isolate;` background (three layers):
`radial-gradient(120% 90% at 20% 15%, hsl(var(--h) 55% 60% / .95), transparent 60%)`,
`radial-gradient(90% 70% at 85% 92%, hsl(calc(var(--h) + 40) 60% 32% / .9), transparent 65%)`,
`linear-gradient(165deg, hsl(var(--h) 30% 20%), hsl(calc(var(--h) + 20) 40% 7%))`.
- `.vs__tile::before`: full-cover grain — an inline SVG data-URI `feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="2"` tile (180×180), `opacity: .45; mix-blend-mode: overlay;`
- `.vs__tile::after`: `linear-gradient(to top, rgba(0,0,0,.55), transparent 45%)` so captions stay legible.
- `.vs__cap`: `position: absolute; left: 1.1rem; bottom: 1rem;` mono 11px uppercase `letter-spacing: .12em; color: rgba(255,255,255,.85);` index span at `opacity: .55`.
- `.vs__readout`: `position: fixed; right: 1.5rem; bottom: 1.5rem; z-index: 5; display: grid; gap: .4rem; padding: .8rem 1rem; border: 1px solid var(--line); border-radius: 8px; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px);` mono 11px uppercase muted, `pointer-events: none;` numbers `font-variant-numeric: tabular-nums; color: var(--text)`.
- `.vs__readout-bar`: `width: 140px; height: 1px; background: var(--line, rgba(255,255,255,.08)); overflow: hidden;` `.vs__readout-fill { height: 100%; width: 100%; background: var(--accent, #d4ff3a); transform-origin: left; transform: scaleX(0); }`
- Reduced motion: `.vs__readout { display: none; }`, `.vs__item { will-change: auto; }`.
**Animation spec (exact values):**
Constants: `MAX_SKEW = 8` (deg), `SKEW_DIVISOR = 350` (px/s per degree), `MAX_SCALE_DROP = 0.06`, `WORD_SHIFT = 48` (px), `RETURN = 0.8` (s), `RETURN_EASE = 'power3.out'`.
1. Setters (created once): `skewSet = gsap.quickSetter(items, 'skewY', 'deg')`, `scaleSet = gsap.quickSetter(items, 'scale')`, `wordSet = gsap.quickSetter(words, 'x', 'px')`, `fillSet = gsap.quickSetter('.vs__readout-fill', 'scaleX')`.
2. A proxy `state = { v: 0 }` holds the *smoothed* velocity. `render()` derives everything from it:
- `skew = clamp(-maxSkew, maxSkew, -state.v / 350)` → `skewSet(skew)` (scrolling down = positive velocity = negative skew; the grid leans "back").
- `scaleSet(1 - Math.min(|v| / 8000, 0.06))` → 1 at rest, 0.94 at ≥ 480 px/s… (full drop at 480 px/s × 8000/480 — i.e. at |v| ≥ 480 the drop saturates).
- `wordSet(clamp(-48, 48, v / 80))`.
- `fillSet(|skew| / 8)`; readout text: velocity as `±dddd` (clamped to ±9999), skew as `±d.d`.
3. Smoothing: `velTo = gsap.quickTo(state, 'v', { duration: 0.8, ease: 'power3.out', onUpdate: render })`. Every call retargets the same tween (no new tweens per event).
4. Return to zero: `settle = gsap.delayedCall(0.15, () => velTo(0)).pause()`.
5. Velocity source:
```js
ScrollTrigger.create({ start: 0, end: 'max', markers: false,
onUpdate(self) { velTo(self.getVelocity()); settle.restart(true); } });
```
`onUpdate` fires on every scroll frame; when scrolling stops it stops firing, the 0.15s `settle` elapses and `velTo(0)` eases the proxy — and therefore skew, scale, shift and readout — back to rest over 0.8s with `power3.out`.
6. Intro: `gsap.from('.vs-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`.
7. `document.fonts.ready.then(() => ScrollTrigger.refresh())`.
**Interaction:** Purely scroll-driven; direction of the lean follows scroll direction, magnitude follows speed. No hover or click behaviour. Keyboard/space-bar scrolling produces short bursts that lean and settle.
**Responsive behavior:** `gsap.matchMedia()` with `'(max-width: 767px), (hover: none) and (pointer: coarse)'` lowers the clamp to `maxSkew = 8 × 5/8` (5° at the default — touch flicks are fast and cards are small) and restores 8 on revert. Grid drops to two columns under 768px; the readout moves to `right: 1rem; bottom: 1rem` with a 100px bar. Word size is fluid via `clamp(3.5rem, 13vw, 13rem)`.
**Accessibility:** With `prefers-reduced-motion: reduce` do not create the ScrollTrigger or the setters — the grid is static and upright and the readout is hidden. The readout is `aria-live="off"` so screen readers are not spammed by velocity numbers. Tiles are `<article>`s with visible captions; words are `<h2>`s.
**Performance rules:** Only `transform` is written (`skewY`, `scale`, `x`, `scaleX`), via `quickSetter` — never `gsap.to` inside `onUpdate`. One `quickTo` tween does all the easing. `will-change: transform` on `.vs__item` (removed under reduced motion). Everything is created inside `gsap.context()` and reverted on unmount (`ctx.revert()` kills the ScrollTrigger, the quickTo tween and the delayedCall).
**Customization knobs:**
- `MAX_SKEW` (8) and the mobile clamp (5/8 of it).
- `SKEW_DIVISOR` (350) — lower = more sensitive.
- `RETURN` (0.8) and its ease (`power3.out`; `power3.out` is the soft default, `expo.out` for a snappier return, `elastic.out(1, 0.6)` for a wobble).
- `MAX_SCALE_DROP` (0.06) and `WORD_SHIFT` (48) — set to 0 to disable.
- Readout bar colour (`#d4ff3a`).
- Tile hues: the `--h` inline variable per tile.
**Acceptance criteria:**
- [ ] Grid is upright at rest; a fast scroll leans every item (same angle) and shrinks it slightly; opposite direction leans the other way.
- [ ] Skew never exceeds ±8° on desktop / 5/8 of that on phones no matter how fast the scroll.
- [ ] After scrolling stops, items return to 0° within ~0.8s (+0.15s idle delay) with no snap; the readout returns to +0000 / +0.0.
- [ ] Readout bar fills proportionally to |skew| and is empty at rest.
- [ ] Reduced-motion users see a static grid and no readout.
- [ ] No console errors; no tweens created per scroll event. Add this effect to my existing site/section: **Scroll Velocity Skew**. Treat my existing grid/list items as `.vs__item` (give them `will-change: transform; transform-origin: 50% 50%`). With GSAP + ScrollTrigger create `skewSet = gsap.quickSetter(items, 'skewY', 'deg')` and `scaleSet = gsap.quickSetter(items, 'scale')`, a proxy `state = { v: 0 }`, and `velTo = gsap.quickTo(state, 'v', { duration: 0.8, ease: 'power3.out', onUpdate: render })` where `render` writes `skewSet(clamp(-8, 8, -state.v / 350))` and `scaleSet(1 - Math.min(Math.abs(state.v) / 8000, 0.06))`. Create `ScrollTrigger.create({ start: 0, end: 'max', onUpdate: self => { velTo(self.getVelocity()); settle.restart(true); } })` with `settle = gsap.delayedCall(0.15, () => velTo(0)).pause()` so everything eases back to upright when scrolling stops. Clamp to 5/8 of that under 768px / on touch via `gsap.matchMedia()`. Skip all of it under `prefers-reduced-motion`. Optionally add a fixed mono readout of velocity and skew with a `#d4ff3a` fill bar, and drift any oversized words sideways by up to 48px. Wrap in `gsap.context()`, revert on unmount, and leave my colours, fonts and layout untouched. ### EFFECT (React / Next.js): Scroll Velocity Skew
Build **Scroll Velocity Skew** 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/ScrollVelocitySkew.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, ScrollTrigger);
export default function ScrollVelocitySkew() {
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="vs-intro">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`ScrollVelocitySkew.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: Scroll Velocity Skew
**What it does (visual):** A centred intro ("Momentum") leads into a long three-column grid: twelve portrait tiles painted as soft, grainy duotone gradients (each with a mono caption like "01 Harbour") interleaved with four full-width oversized serif words — "Velocity", "Inertia", "Drift", "Settle" — alternating left and right aligned. At rest everything is perfectly upright. When the user scrolls, every tile and word shears vertically (`skewY`) in proportion to scroll speed — scrolling down leans them one way, scrolling up the other — up to ±8°, while shrinking very slightly (down to 0.94) and the big words drift sideways up to 48px. The motion is smoothed, so a flick produces a lean that builds over a fraction of a second and, once the page stops, eases back to upright over 0.8s with a soft deceleration (`power3.out`). A small fixed mono readout pill in the bottom-right shows "velocity +1234 px/s", "skew −3.5°" and a 140px hairline bar that fills with the accent colour `#d4ff3a` as the skew approaches its clamp.
**Tech stack:**
- GSAP 3.13+ and ScrollTrigger (for `getVelocity()`), `gsap.quickTo`, `gsap.quickSetter`, `gsap.utils.clamp`.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);`
- Works with Lenis (`lenis.on('scroll', ScrollTrigger.update)`); velocity is read from ScrollTrigger either way.
**HTML structure:**
```html
<section class="vs-intro"><p class="vs-intro__label">Scroll · Velocity skew</p><h1 class="vs-intro__title">Momentum</h1></section>
<section class="vs">
<div class="vs__grid">
<h2 class="vs__item vs__word">Velocity</h2>
<article class="vs__item vs__tile" style="--h:196"><p class="vs__cap"><span>01</span>Harbour</p></article>
<article class="vs__item vs__tile" style="--h:28"><p class="vs__cap"><span>02</span>Kiln</p></article>
<article class="vs__item vs__tile" style="--h:262"><p class="vs__cap"><span>03</span>Meridian</p></article>
<h2 class="vs__item vs__word vs__word--right">Inertia</h2>
<!-- …3 tiles, word, 3 tiles, word, 3 tiles: 12 tiles + 4 words total -->
</div>
</section>
<section class="vs-outro"><p>Everything has returned to zero.</p></section>
<aside class="vs__readout" aria-live="off">
<span>velocity <b class="vs__read-v">+0000</b> px/s</span>
<span>skew <b class="vs__read-s">+0.0</b>°</span>
<span class="vs__readout-bar"><i class="vs__readout-fill"></i></span>
</aside>
```
(Tiles are CSS gradients driven by a `--h` hue so no image assets are needed; swap in `<img>`s in production.)
**CSS requirements:**
- `.vs-intro`, `.vs-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center;`
- `.vs`: `padding: 4vw 6vw 10vw;`
- `.vs__grid`: `display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(1rem, 2.5vw, 2.5rem); align-items: center;` → `repeat(2, 1fr)` under 768px.
- `.vs__item`: `will-change: transform; transform-origin: 50% 50%;` (centre origin so shearing doesn't look like a shift).
- `.vs__word`: `grid-column: 1 / -1;` display serif, `font-weight: 300; font-size: clamp(3.5rem, 13vw, 13rem); letter-spacing: -0.04em; line-height: .85; padding: .25em 0 .1em;` `.vs__word--right { text-align: right; font-style: italic; font-weight: 200; }`
- `.vs__tile`: `position: relative; aspect-ratio: 4 / 5; border-radius: 10px; overflow: hidden; isolation: isolate;` background (three layers):
`radial-gradient(120% 90% at 20% 15%, hsl(var(--h) 55% 60% / .95), transparent 60%)`,
`radial-gradient(90% 70% at 85% 92%, hsl(calc(var(--h) + 40) 60% 32% / .9), transparent 65%)`,
`linear-gradient(165deg, hsl(var(--h) 30% 20%), hsl(calc(var(--h) + 20) 40% 7%))`.
- `.vs__tile::before`: full-cover grain — an inline SVG data-URI `feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="2"` tile (180×180), `opacity: .45; mix-blend-mode: overlay;`
- `.vs__tile::after`: `linear-gradient(to top, rgba(0,0,0,.55), transparent 45%)` so captions stay legible.
- `.vs__cap`: `position: absolute; left: 1.1rem; bottom: 1rem;` mono 11px uppercase `letter-spacing: .12em; color: rgba(255,255,255,.85);` index span at `opacity: .55`.
- `.vs__readout`: `position: fixed; right: 1.5rem; bottom: 1.5rem; z-index: 5; display: grid; gap: .4rem; padding: .8rem 1rem; border: 1px solid var(--line); border-radius: 8px; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px);` mono 11px uppercase muted, `pointer-events: none;` numbers `font-variant-numeric: tabular-nums; color: var(--text)`.
- `.vs__readout-bar`: `width: 140px; height: 1px; background: var(--line, rgba(255,255,255,.08)); overflow: hidden;` `.vs__readout-fill { height: 100%; width: 100%; background: var(--accent, #d4ff3a); transform-origin: left; transform: scaleX(0); }`
- Reduced motion: `.vs__readout { display: none; }`, `.vs__item { will-change: auto; }`.
**Animation spec (exact values):**
Constants: `MAX_SKEW = 8` (deg), `SKEW_DIVISOR = 350` (px/s per degree), `MAX_SCALE_DROP = 0.06`, `WORD_SHIFT = 48` (px), `RETURN = 0.8` (s), `RETURN_EASE = 'power3.out'`.
1. Setters (created once): `skewSet = gsap.quickSetter(items, 'skewY', 'deg')`, `scaleSet = gsap.quickSetter(items, 'scale')`, `wordSet = gsap.quickSetter(words, 'x', 'px')`, `fillSet = gsap.quickSetter('.vs__readout-fill', 'scaleX')`.
2. A proxy `state = { v: 0 }` holds the *smoothed* velocity. `render()` derives everything from it:
- `skew = clamp(-maxSkew, maxSkew, -state.v / 350)` → `skewSet(skew)` (scrolling down = positive velocity = negative skew; the grid leans "back").
- `scaleSet(1 - Math.min(|v| / 8000, 0.06))` → 1 at rest, 0.94 at ≥ 480 px/s… (full drop at 480 px/s × 8000/480 — i.e. at |v| ≥ 480 the drop saturates).
- `wordSet(clamp(-48, 48, v / 80))`.
- `fillSet(|skew| / 8)`; readout text: velocity as `±dddd` (clamped to ±9999), skew as `±d.d`.
3. Smoothing: `velTo = gsap.quickTo(state, 'v', { duration: 0.8, ease: 'power3.out', onUpdate: render })`. Every call retargets the same tween (no new tweens per event).
4. Return to zero: `settle = gsap.delayedCall(0.15, () => velTo(0)).pause()`.
5. Velocity source:
```js
ScrollTrigger.create({ start: 0, end: 'max', markers: false,
onUpdate(self) { velTo(self.getVelocity()); settle.restart(true); } });
```
`onUpdate` fires on every scroll frame; when scrolling stops it stops firing, the 0.15s `settle` elapses and `velTo(0)` eases the proxy — and therefore skew, scale, shift and readout — back to rest over 0.8s with `power3.out`.
6. Intro: `gsap.from('.vs-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`.
7. `document.fonts.ready.then(() => ScrollTrigger.refresh())`.
**Interaction:** Purely scroll-driven; direction of the lean follows scroll direction, magnitude follows speed. No hover or click behaviour. Keyboard/space-bar scrolling produces short bursts that lean and settle.
**Responsive behavior:** `gsap.matchMedia()` with `'(max-width: 767px), (hover: none) and (pointer: coarse)'` lowers the clamp to `maxSkew = 8 × 5/8` (5° at the default — touch flicks are fast and cards are small) and restores 8 on revert. Grid drops to two columns under 768px; the readout moves to `right: 1rem; bottom: 1rem` with a 100px bar. Word size is fluid via `clamp(3.5rem, 13vw, 13rem)`.
**Accessibility:** With `prefers-reduced-motion: reduce` do not create the ScrollTrigger or the setters — the grid is static and upright and the readout is hidden. The readout is `aria-live="off"` so screen readers are not spammed by velocity numbers. Tiles are `<article>`s with visible captions; words are `<h2>`s.
**Performance rules:** Only `transform` is written (`skewY`, `scale`, `x`, `scaleX`), via `quickSetter` — never `gsap.to` inside `onUpdate`. One `quickTo` tween does all the easing. `will-change: transform` on `.vs__item` (removed under reduced motion). Everything is created inside `gsap.context()` and reverted on unmount (`ctx.revert()` kills the ScrollTrigger, the quickTo tween and the delayedCall).
**Customization knobs:**
- `MAX_SKEW` (8) and the mobile clamp (5/8 of it).
- `SKEW_DIVISOR` (350) — lower = more sensitive.
- `RETURN` (0.8) and its ease (`power3.out`; `power3.out` is the soft default, `expo.out` for a snappier return, `elastic.out(1, 0.6)` for a wobble).
- `MAX_SCALE_DROP` (0.06) and `WORD_SHIFT` (48) — set to 0 to disable.
- Readout bar colour (`#d4ff3a`).
- Tile hues: the `--h` inline variable per tile.
**Acceptance criteria:**
- [ ] Grid is upright at rest; a fast scroll leans every item (same angle) and shrinks it slightly; opposite direction leans the other way.
- [ ] Skew never exceeds ±8° on desktop / 5/8 of that on phones no matter how fast the scroll.
- [ ] After scrolling stops, items return to 0° within ~0.8s (+0.15s idle delay) with no snap; the readout returns to +0000 / +0.0.
- [ ] Readout bar fills proportionally to |skew| and is empty at rest.
- [ ] Reduced-motion users see a static grid and no readout.
- [ ] No console errors; no tweens created per scroll event.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<section class="vs-intro">
<p class="vs-intro__label">Scroll · Velocity skew</p>
<h1 class="vs-intro__title">Momentum</h1>
<p class="vs-intro__copy">Scroll fast and the page leans with you. Stop, and everything settles back upright.</p>
</section>
<section class="vs">
<div class="vs__grid">
<h2 class="vs__item vs__word">Velocity</h2>
<article class="vs__item vs__tile" style="--h:196"><p class="vs__cap"><span>01</span>Harbour</p></article>
<article class="vs__item vs__tile" style="--h:28"><p class="vs__cap"><span>02</span>Kiln</p></article>
<article class="vs__item vs__tile" style="--h:262"><p class="vs__cap"><span>03</span>Meridian</p></article>
<h2 class="vs__item vs__word vs__word--right">Inertia</h2>
<article class="vs__item vs__tile" style="--h:160"><p class="vs__cap"><span>04</span>Tidal</p></article>
<article class="vs__item vs__tile" style="--h:92"><p class="vs__cap"><span>05</span>Grove</p></article>
<article class="vs__item vs__tile" style="--h:8"><p class="vs__cap"><span>06</span>Cinder</p></article>
<h2 class="vs__item vs__word">Drift</h2>
<article class="vs__item vs__tile" style="--h:42"><p class="vs__cap"><span>07</span>Lantern</p></article>
<article class="vs__item vs__tile" style="--h:214"><p class="vs__cap"><span>08</span>Quarry</p></article>
<article class="vs__item vs__tile" style="--h:330"><p class="vs__cap"><span>09</span>Sable</p></article>
<h2 class="vs__item vs__word vs__word--right">Settle</h2>
<article class="vs__item vs__tile" style="--h:124"><p class="vs__cap"><span>10</span>Vale</p></article>
<article class="vs__item vs__tile" style="--h:240"><p class="vs__cap"><span>11</span>Nocturne</p></article>
<article class="vs__item vs__tile" style="--h:70"><p class="vs__cap"><span>12</span>Ridge</p></article>
</div>
</section>
<section class="vs-outro">
<p class="vs-outro__text">Everything has returned to zero. The page continues normally.</p>
</section>
<aside class="vs__readout" aria-live="off">
<span class="vs__readout-row">velocity <b class="vs__read-v">+0000</b> px/s</span>
<span class="vs__readout-row">skew <b class="vs__read-s">+0.0</b>°</span>
<span class="vs__readout-bar"><i class="vs__readout-fill"></i></span>
</aside>
```
```css
.vs-intro, .vs-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center; }
.vs-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.vs-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(3rem, 10vw, 10rem); letter-spacing: -0.04em; line-height: .9; font-variation-settings: 'opsz' 144, 'SOFT' 20; }
.vs-intro__copy { max-width: 40ch; margin: 2rem auto 0; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.1rem); line-height: 1.5; }
.vs-outro__text { color: var(--muted); max-width: 40ch; }
.vs { padding: 4vw 6vw 10vw; }
.vs__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(1rem, 2.5vw, 2.5rem); align-items: center; }
.vs__item { will-change: transform; transform-origin: 50% 50%; }
.vs__word { grid-column: 1 / -1; font-family: var(--font-display); font-weight: 300; font-size: clamp(3.5rem, 13vw, 13rem); letter-spacing: -0.04em; line-height: .85; padding: .25em 0 .1em; font-variation-settings: 'opsz' 144, 'SOFT' 40; }
.vs__word--right { text-align: right; font-style: italic; font-weight: 200; }
.vs__tile {
position: relative; aspect-ratio: 4 / 5; border-radius: 10px; overflow: hidden; isolation: isolate;
background:
radial-gradient(120% 90% at 20% 15%, hsl(var(--h) 55% 60% / .95), transparent 60%),
radial-gradient(90% 70% at 85% 92%, hsl(calc(var(--h) + 40) 60% 32% / .9), transparent 65%),
linear-gradient(165deg, hsl(var(--h) 30% 20%), hsl(calc(var(--h) + 20) 40% 7%));
}
.vs__tile::before { content: ''; position: absolute; inset: 0; z-index: 1; opacity: .45; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
.vs__tile::after { content: ''; position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, rgba(0,0,0,.55), transparent 45%); }
.vs__cap { position: absolute; left: 1.1rem; bottom: 1rem; z-index: 2; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: rgba(255,255,255,.85); display: flex; gap: .9em; }
.vs__cap span { opacity: .55; }
.vs__readout { position: fixed; right: 1.5rem; bottom: 1.5rem; z-index: 5; display: grid; gap: .4rem; padding: .8rem 1rem; border: 1px solid var(--line); border-radius: 8px; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); pointer-events: none; }
.vs__readout b { font-weight: 500; color: var(--text); font-variant-numeric: tabular-nums; }
.vs__readout-bar { display: block; width: 140px; height: 1px; background: var(--line); overflow: hidden; }
.vs__readout-fill { display: block; height: 100%; width: 100%; background: var(--knob-accent, var(--accent)); transform-origin: left; transform: scaleX(0); }
@media (max-width: 767px) {
.vs__grid { grid-template-columns: repeat(2, 1fr); }
.vs__readout { right: 1rem; bottom: 1rem; padding: .6rem .8rem; }
.vs__readout-bar { width: 100px; }
}
@media (prefers-reduced-motion: reduce) {
.vs__item { will-change: auto; }
.vs__readout { display: none; }
}
```
```js
// Scroll Velocity Skew — the grid leans with scroll speed and settles back to zero.
gsap.registerPlugin(ScrollTrigger);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { maxSkew: 8, sensitivity: 350, settle: 0.8, ease: 'power3.out', wordShift: 48, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const MAX_SCALE_DROP = 0.06; // items shrink to 0.94 at full speed
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const poster = new URLSearchParams(location.search).get('thumb') === '1'; // library poster: hold the peak a little longer
const items = gsap.utils.toArray('.vs__item');
const words = gsap.utils.toArray('.vs__word');
const readout = document.querySelector('.vs__readout');
const readV = document.querySelector('.vs__read-v');
const readS = document.querySelector('.vs__read-s');
const fill = document.querySelector('.vs__readout-fill');
const fmt = (n, d = 0) => (n < 0 ? '−' : '+') + Math.abs(n).toFixed(d).padStart(d ? 3 : 4, '0');
let ctx;
function build() {
ctx?.revert();
const MAX_SKEW = K('maxSkew'); // degrees, clamped
const SKEW_DIVISOR = K('sensitivity'); // px/s of scroll velocity per degree of skew
const RETURN = K('settle'); // seconds to settle back
const EASE = K('ease');
const WORD_SHIFT = K('wordShift'); // px the big words drift sideways at full speed
readout.style.setProperty('--knob-accent', K('accent'));
ctx = gsap.context(() => {
gsap.from('.vs-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) return; // static grid, no readout
// Reset whatever a previous build's setters left behind.
gsap.set(items, { skewY: 0, scale: 1, x: 0 });
gsap.set(fill, { scaleX: 0 });
readV.textContent = fmt(0); readS.textContent = fmt(0, 1);
let maxSkew = MAX_SKEW;
const state = { v: 0 };
const clamp = gsap.utils.clamp;
const skewSet = gsap.quickSetter(items, 'skewY', 'deg');
const scaleSet = gsap.quickSetter(items, 'scale');
const wordSet = gsap.quickSetter(words, 'x', 'px');
const fillSet = gsap.quickSetter(fill, 'scaleX');
const render = () => {
const v = state.v;
const skew = clamp(-maxSkew, maxSkew, -v / SKEW_DIVISOR);
skewSet(skew);
scaleSet(1 - Math.min(Math.abs(v) / 8000, MAX_SCALE_DROP));
wordSet(clamp(-WORD_SHIFT, WORD_SHIFT, v / 80));
fillSet(Math.abs(skew) / MAX_SKEW);
readV.textContent = fmt(clamp(-9999, 9999, v));
readS.textContent = fmt(skew, 1);
};
// Smoothed velocity: every scroll update retargets the quickTo, so the easing never restarts abruptly.
const velTo = gsap.quickTo(state, 'v', { duration: RETURN, ease: EASE, onUpdate: render });
const settle = gsap.delayedCall(poster ? 3 : 0.15, () => velTo(0)).pause();
ScrollTrigger.create({
start: 0, end: 'max', markers: false,
onUpdate(self) {
const v = self.getVelocity();
if (poster && Math.abs(v) < Math.abs(state.v)) return; // poster: keep the peak so the thumbnail shows the lean
velTo(v); settle.restart(true);
},
});
const mm = gsap.matchMedia();
mm.add('(max-width: 767px), (hover: none) and (pointer: coarse)', () => {
maxSkew = MAX_SKEW * 0.625; // smaller cards + touch flicks: tame the lean (5° at the default 8°)
return () => { maxSkew = MAX_SKEW; };
});
document.fonts.ready.then(() => ScrollTrigger.refresh());
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (single HTML file): Scroll Velocity Skew
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Scroll Velocity Skew** 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>Scroll Velocity Skew</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### EFFECT: Scroll Velocity Skew
**What it does (visual):** A centred intro ("Momentum") leads into a long three-column grid: twelve portrait tiles painted as soft, grainy duotone gradients (each with a mono caption like "01 Harbour") interleaved with four full-width oversized serif words — "Velocity", "Inertia", "Drift", "Settle" — alternating left and right aligned. At rest everything is perfectly upright. When the user scrolls, every tile and word shears vertically (`skewY`) in proportion to scroll speed — scrolling down leans them one way, scrolling up the other — up to ±8°, while shrinking very slightly (down to 0.94) and the big words drift sideways up to 48px. The motion is smoothed, so a flick produces a lean that builds over a fraction of a second and, once the page stops, eases back to upright over 0.8s with a soft deceleration (`power3.out`). A small fixed mono readout pill in the bottom-right shows "velocity +1234 px/s", "skew −3.5°" and a 140px hairline bar that fills with the accent colour `#d4ff3a` as the skew approaches its clamp.
**Tech stack:**
- GSAP 3.13+ and ScrollTrigger (for `getVelocity()`), `gsap.quickTo`, `gsap.quickSetter`, `gsap.utils.clamp`.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);`
- Works with Lenis (`lenis.on('scroll', ScrollTrigger.update)`); velocity is read from ScrollTrigger either way.
**HTML structure:**
```html
<section class="vs-intro"><p class="vs-intro__label">Scroll · Velocity skew</p><h1 class="vs-intro__title">Momentum</h1></section>
<section class="vs">
<div class="vs__grid">
<h2 class="vs__item vs__word">Velocity</h2>
<article class="vs__item vs__tile" style="--h:196"><p class="vs__cap"><span>01</span>Harbour</p></article>
<article class="vs__item vs__tile" style="--h:28"><p class="vs__cap"><span>02</span>Kiln</p></article>
<article class="vs__item vs__tile" style="--h:262"><p class="vs__cap"><span>03</span>Meridian</p></article>
<h2 class="vs__item vs__word vs__word--right">Inertia</h2>
<!-- …3 tiles, word, 3 tiles, word, 3 tiles: 12 tiles + 4 words total -->
</div>
</section>
<section class="vs-outro"><p>Everything has returned to zero.</p></section>
<aside class="vs__readout" aria-live="off">
<span>velocity <b class="vs__read-v">+0000</b> px/s</span>
<span>skew <b class="vs__read-s">+0.0</b>°</span>
<span class="vs__readout-bar"><i class="vs__readout-fill"></i></span>
</aside>
```
(Tiles are CSS gradients driven by a `--h` hue so no image assets are needed; swap in `<img>`s in production.)
**CSS requirements:**
- `.vs-intro`, `.vs-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center;`
- `.vs`: `padding: 4vw 6vw 10vw;`
- `.vs__grid`: `display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(1rem, 2.5vw, 2.5rem); align-items: center;` → `repeat(2, 1fr)` under 768px.
- `.vs__item`: `will-change: transform; transform-origin: 50% 50%;` (centre origin so shearing doesn't look like a shift).
- `.vs__word`: `grid-column: 1 / -1;` display serif, `font-weight: 300; font-size: clamp(3.5rem, 13vw, 13rem); letter-spacing: -0.04em; line-height: .85; padding: .25em 0 .1em;` `.vs__word--right { text-align: right; font-style: italic; font-weight: 200; }`
- `.vs__tile`: `position: relative; aspect-ratio: 4 / 5; border-radius: 10px; overflow: hidden; isolation: isolate;` background (three layers):
`radial-gradient(120% 90% at 20% 15%, hsl(var(--h) 55% 60% / .95), transparent 60%)`,
`radial-gradient(90% 70% at 85% 92%, hsl(calc(var(--h) + 40) 60% 32% / .9), transparent 65%)`,
`linear-gradient(165deg, hsl(var(--h) 30% 20%), hsl(calc(var(--h) + 20) 40% 7%))`.
- `.vs__tile::before`: full-cover grain — an inline SVG data-URI `feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="2"` tile (180×180), `opacity: .45; mix-blend-mode: overlay;`
- `.vs__tile::after`: `linear-gradient(to top, rgba(0,0,0,.55), transparent 45%)` so captions stay legible.
- `.vs__cap`: `position: absolute; left: 1.1rem; bottom: 1rem;` mono 11px uppercase `letter-spacing: .12em; color: rgba(255,255,255,.85);` index span at `opacity: .55`.
- `.vs__readout`: `position: fixed; right: 1.5rem; bottom: 1.5rem; z-index: 5; display: grid; gap: .4rem; padding: .8rem 1rem; border: 1px solid var(--line); border-radius: 8px; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px);` mono 11px uppercase muted, `pointer-events: none;` numbers `font-variant-numeric: tabular-nums; color: var(--text)`.
- `.vs__readout-bar`: `width: 140px; height: 1px; background: var(--line, rgba(255,255,255,.08)); overflow: hidden;` `.vs__readout-fill { height: 100%; width: 100%; background: var(--accent, #d4ff3a); transform-origin: left; transform: scaleX(0); }`
- Reduced motion: `.vs__readout { display: none; }`, `.vs__item { will-change: auto; }`.
**Animation spec (exact values):**
Constants: `MAX_SKEW = 8` (deg), `SKEW_DIVISOR = 350` (px/s per degree), `MAX_SCALE_DROP = 0.06`, `WORD_SHIFT = 48` (px), `RETURN = 0.8` (s), `RETURN_EASE = 'power3.out'`.
1. Setters (created once): `skewSet = gsap.quickSetter(items, 'skewY', 'deg')`, `scaleSet = gsap.quickSetter(items, 'scale')`, `wordSet = gsap.quickSetter(words, 'x', 'px')`, `fillSet = gsap.quickSetter('.vs__readout-fill', 'scaleX')`.
2. A proxy `state = { v: 0 }` holds the *smoothed* velocity. `render()` derives everything from it:
- `skew = clamp(-maxSkew, maxSkew, -state.v / 350)` → `skewSet(skew)` (scrolling down = positive velocity = negative skew; the grid leans "back").
- `scaleSet(1 - Math.min(|v| / 8000, 0.06))` → 1 at rest, 0.94 at ≥ 480 px/s… (full drop at 480 px/s × 8000/480 — i.e. at |v| ≥ 480 the drop saturates).
- `wordSet(clamp(-48, 48, v / 80))`.
- `fillSet(|skew| / 8)`; readout text: velocity as `±dddd` (clamped to ±9999), skew as `±d.d`.
3. Smoothing: `velTo = gsap.quickTo(state, 'v', { duration: 0.8, ease: 'power3.out', onUpdate: render })`. Every call retargets the same tween (no new tweens per event).
4. Return to zero: `settle = gsap.delayedCall(0.15, () => velTo(0)).pause()`.
5. Velocity source:
```js
ScrollTrigger.create({ start: 0, end: 'max', markers: false,
onUpdate(self) { velTo(self.getVelocity()); settle.restart(true); } });
```
`onUpdate` fires on every scroll frame; when scrolling stops it stops firing, the 0.15s `settle` elapses and `velTo(0)` eases the proxy — and therefore skew, scale, shift and readout — back to rest over 0.8s with `power3.out`.
6. Intro: `gsap.from('.vs-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`.
7. `document.fonts.ready.then(() => ScrollTrigger.refresh())`.
**Interaction:** Purely scroll-driven; direction of the lean follows scroll direction, magnitude follows speed. No hover or click behaviour. Keyboard/space-bar scrolling produces short bursts that lean and settle.
**Responsive behavior:** `gsap.matchMedia()` with `'(max-width: 767px), (hover: none) and (pointer: coarse)'` lowers the clamp to `maxSkew = 8 × 5/8` (5° at the default — touch flicks are fast and cards are small) and restores 8 on revert. Grid drops to two columns under 768px; the readout moves to `right: 1rem; bottom: 1rem` with a 100px bar. Word size is fluid via `clamp(3.5rem, 13vw, 13rem)`.
**Accessibility:** With `prefers-reduced-motion: reduce` do not create the ScrollTrigger or the setters — the grid is static and upright and the readout is hidden. The readout is `aria-live="off"` so screen readers are not spammed by velocity numbers. Tiles are `<article>`s with visible captions; words are `<h2>`s.
**Performance rules:** Only `transform` is written (`skewY`, `scale`, `x`, `scaleX`), via `quickSetter` — never `gsap.to` inside `onUpdate`. One `quickTo` tween does all the easing. `will-change: transform` on `.vs__item` (removed under reduced motion). Everything is created inside `gsap.context()` and reverted on unmount (`ctx.revert()` kills the ScrollTrigger, the quickTo tween and the delayedCall).
**Customization knobs:**
- `MAX_SKEW` (8) and the mobile clamp (5/8 of it).
- `SKEW_DIVISOR` (350) — lower = more sensitive.
- `RETURN` (0.8) and its ease (`power3.out`; `power3.out` is the soft default, `expo.out` for a snappier return, `elastic.out(1, 0.6)` for a wobble).
- `MAX_SCALE_DROP` (0.06) and `WORD_SHIFT` (48) — set to 0 to disable.
- Readout bar colour (`#d4ff3a`).
- Tile hues: the `--h` inline variable per tile.
**Acceptance criteria:**
- [ ] Grid is upright at rest; a fast scroll leans every item (same angle) and shrinks it slightly; opposite direction leans the other way.
- [ ] Skew never exceeds ±8° on desktop / 5/8 of that on phones no matter how fast the scroll.
- [ ] After scrolling stops, items return to 0° within ~0.8s (+0.15s idle delay) with no snap; the readout returns to +0000 / +0.0.
- [ ] Readout bar fills proportionally to |skew| and is empty at rest.
- [ ] Reduced-motion users see a static grid and no readout.
- [ ] No console errors; no tweens created per scroll event.
---
**Reference implementation (use these exact values and class names):**
```html
<section class="vs-intro">
<p class="vs-intro__label">Scroll · Velocity skew</p>
<h1 class="vs-intro__title">Momentum</h1>
<p class="vs-intro__copy">Scroll fast and the page leans with you. Stop, and everything settles back upright.</p>
</section>
<section class="vs">
<div class="vs__grid">
<h2 class="vs__item vs__word">Velocity</h2>
<article class="vs__item vs__tile" style="--h:196"><p class="vs__cap"><span>01</span>Harbour</p></article>
<article class="vs__item vs__tile" style="--h:28"><p class="vs__cap"><span>02</span>Kiln</p></article>
<article class="vs__item vs__tile" style="--h:262"><p class="vs__cap"><span>03</span>Meridian</p></article>
<h2 class="vs__item vs__word vs__word--right">Inertia</h2>
<article class="vs__item vs__tile" style="--h:160"><p class="vs__cap"><span>04</span>Tidal</p></article>
<article class="vs__item vs__tile" style="--h:92"><p class="vs__cap"><span>05</span>Grove</p></article>
<article class="vs__item vs__tile" style="--h:8"><p class="vs__cap"><span>06</span>Cinder</p></article>
<h2 class="vs__item vs__word">Drift</h2>
<article class="vs__item vs__tile" style="--h:42"><p class="vs__cap"><span>07</span>Lantern</p></article>
<article class="vs__item vs__tile" style="--h:214"><p class="vs__cap"><span>08</span>Quarry</p></article>
<article class="vs__item vs__tile" style="--h:330"><p class="vs__cap"><span>09</span>Sable</p></article>
<h2 class="vs__item vs__word vs__word--right">Settle</h2>
<article class="vs__item vs__tile" style="--h:124"><p class="vs__cap"><span>10</span>Vale</p></article>
<article class="vs__item vs__tile" style="--h:240"><p class="vs__cap"><span>11</span>Nocturne</p></article>
<article class="vs__item vs__tile" style="--h:70"><p class="vs__cap"><span>12</span>Ridge</p></article>
</div>
</section>
<section class="vs-outro">
<p class="vs-outro__text">Everything has returned to zero. The page continues normally.</p>
</section>
<aside class="vs__readout" aria-live="off">
<span class="vs__readout-row">velocity <b class="vs__read-v">+0000</b> px/s</span>
<span class="vs__readout-row">skew <b class="vs__read-s">+0.0</b>°</span>
<span class="vs__readout-bar"><i class="vs__readout-fill"></i></span>
</aside>
```
```css
.vs-intro, .vs-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center; }
.vs-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.vs-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(3rem, 10vw, 10rem); letter-spacing: -0.04em; line-height: .9; font-variation-settings: 'opsz' 144, 'SOFT' 20; }
.vs-intro__copy { max-width: 40ch; margin: 2rem auto 0; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.1rem); line-height: 1.5; }
.vs-outro__text { color: var(--muted); max-width: 40ch; }
.vs { padding: 4vw 6vw 10vw; }
.vs__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(1rem, 2.5vw, 2.5rem); align-items: center; }
.vs__item { will-change: transform; transform-origin: 50% 50%; }
.vs__word { grid-column: 1 / -1; font-family: var(--font-display); font-weight: 300; font-size: clamp(3.5rem, 13vw, 13rem); letter-spacing: -0.04em; line-height: .85; padding: .25em 0 .1em; font-variation-settings: 'opsz' 144, 'SOFT' 40; }
.vs__word--right { text-align: right; font-style: italic; font-weight: 200; }
.vs__tile {
position: relative; aspect-ratio: 4 / 5; border-radius: 10px; overflow: hidden; isolation: isolate;
background:
radial-gradient(120% 90% at 20% 15%, hsl(var(--h) 55% 60% / .95), transparent 60%),
radial-gradient(90% 70% at 85% 92%, hsl(calc(var(--h) + 40) 60% 32% / .9), transparent 65%),
linear-gradient(165deg, hsl(var(--h) 30% 20%), hsl(calc(var(--h) + 20) 40% 7%));
}
.vs__tile::before { content: ''; position: absolute; inset: 0; z-index: 1; opacity: .45; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
.vs__tile::after { content: ''; position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, rgba(0,0,0,.55), transparent 45%); }
.vs__cap { position: absolute; left: 1.1rem; bottom: 1rem; z-index: 2; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: rgba(255,255,255,.85); display: flex; gap: .9em; }
.vs__cap span { opacity: .55; }
.vs__readout { position: fixed; right: 1.5rem; bottom: 1.5rem; z-index: 5; display: grid; gap: .4rem; padding: .8rem 1rem; border: 1px solid var(--line); border-radius: 8px; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); pointer-events: none; }
.vs__readout b { font-weight: 500; color: var(--text); font-variant-numeric: tabular-nums; }
.vs__readout-bar { display: block; width: 140px; height: 1px; background: var(--line); overflow: hidden; }
.vs__readout-fill { display: block; height: 100%; width: 100%; background: var(--knob-accent, var(--accent)); transform-origin: left; transform: scaleX(0); }
@media (max-width: 767px) {
.vs__grid { grid-template-columns: repeat(2, 1fr); }
.vs__readout { right: 1rem; bottom: 1rem; padding: .6rem .8rem; }
.vs__readout-bar { width: 100px; }
}
@media (prefers-reduced-motion: reduce) {
.vs__item { will-change: auto; }
.vs__readout { display: none; }
}
```
```js
// Scroll Velocity Skew — the grid leans with scroll speed and settles back to zero.
gsap.registerPlugin(ScrollTrigger);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { maxSkew: 8, sensitivity: 350, settle: 0.8, ease: 'power3.out', wordShift: 48, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const MAX_SCALE_DROP = 0.06; // items shrink to 0.94 at full speed
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const poster = new URLSearchParams(location.search).get('thumb') === '1'; // library poster: hold the peak a little longer
const items = gsap.utils.toArray('.vs__item');
const words = gsap.utils.toArray('.vs__word');
const readout = document.querySelector('.vs__readout');
const readV = document.querySelector('.vs__read-v');
const readS = document.querySelector('.vs__read-s');
const fill = document.querySelector('.vs__readout-fill');
const fmt = (n, d = 0) => (n < 0 ? '−' : '+') + Math.abs(n).toFixed(d).padStart(d ? 3 : 4, '0');
let ctx;
function build() {
ctx?.revert();
const MAX_SKEW = K('maxSkew'); // degrees, clamped
const SKEW_DIVISOR = K('sensitivity'); // px/s of scroll velocity per degree of skew
const RETURN = K('settle'); // seconds to settle back
const EASE = K('ease');
const WORD_SHIFT = K('wordShift'); // px the big words drift sideways at full speed
readout.style.setProperty('--knob-accent', K('accent'));
ctx = gsap.context(() => {
gsap.from('.vs-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) return; // static grid, no readout
// Reset whatever a previous build's setters left behind.
gsap.set(items, { skewY: 0, scale: 1, x: 0 });
gsap.set(fill, { scaleX: 0 });
readV.textContent = fmt(0); readS.textContent = fmt(0, 1);
let maxSkew = MAX_SKEW;
const state = { v: 0 };
const clamp = gsap.utils.clamp;
const skewSet = gsap.quickSetter(items, 'skewY', 'deg');
const scaleSet = gsap.quickSetter(items, 'scale');
const wordSet = gsap.quickSetter(words, 'x', 'px');
const fillSet = gsap.quickSetter(fill, 'scaleX');
const render = () => {
const v = state.v;
const skew = clamp(-maxSkew, maxSkew, -v / SKEW_DIVISOR);
skewSet(skew);
scaleSet(1 - Math.min(Math.abs(v) / 8000, MAX_SCALE_DROP));
wordSet(clamp(-WORD_SHIFT, WORD_SHIFT, v / 80));
fillSet(Math.abs(skew) / MAX_SKEW);
readV.textContent = fmt(clamp(-9999, 9999, v));
readS.textContent = fmt(skew, 1);
};
// Smoothed velocity: every scroll update retargets the quickTo, so the easing never restarts abruptly.
const velTo = gsap.quickTo(state, 'v', { duration: RETURN, ease: EASE, onUpdate: render });
const settle = gsap.delayedCall(poster ? 3 : 0.15, () => velTo(0)).pause();
ScrollTrigger.create({
start: 0, end: 'max', markers: false,
onUpdate(self) {
const v = self.getVelocity();
if (poster && Math.abs(v) < Math.abs(state.v)) return; // poster: keep the peak so the thumbnail shows the lean
velTo(v); settle.restart(true);
},
});
const mm = gsap.matchMedia();
mm.add('(max-width: 767px), (hover: none) and (pointer: coarse)', () => {
maxSkew = MAX_SKEW * 0.625; // smaller cards + touch flicks: tame the lean (5° at the default 8°)
return () => { maxSkew = MAX_SKEW; };
});
document.fonts.ready.then(() => ScrollTrigger.refresh());
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (Framer & Webflow notes): Scroll Velocity Skew
**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 withScrollVelocitySkew(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis. ScrollTrigger works on the page scroller — call `ScrollTrigger.refresh()` after images load.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `vs-intro`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the pinning/scrubbing in code — IX2 cannot do pin spacing or `containerAnimation`.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### EFFECT: Scroll Velocity Skew
**What it does (visual):** A centred intro ("Momentum") leads into a long three-column grid: twelve portrait tiles painted as soft, grainy duotone gradients (each with a mono caption like "01 Harbour") interleaved with four full-width oversized serif words — "Velocity", "Inertia", "Drift", "Settle" — alternating left and right aligned. At rest everything is perfectly upright. When the user scrolls, every tile and word shears vertically (`skewY`) in proportion to scroll speed — scrolling down leans them one way, scrolling up the other — up to ±8°, while shrinking very slightly (down to 0.94) and the big words drift sideways up to 48px. The motion is smoothed, so a flick produces a lean that builds over a fraction of a second and, once the page stops, eases back to upright over 0.8s with a soft deceleration (`power3.out`). A small fixed mono readout pill in the bottom-right shows "velocity +1234 px/s", "skew −3.5°" and a 140px hairline bar that fills with the accent colour `#d4ff3a` as the skew approaches its clamp.
**Tech stack:**
- GSAP 3.13+ and ScrollTrigger (for `getVelocity()`), `gsap.quickTo`, `gsap.quickSetter`, `gsap.utils.clamp`.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);`
- Works with Lenis (`lenis.on('scroll', ScrollTrigger.update)`); velocity is read from ScrollTrigger either way.
**HTML structure:**
```html
<section class="vs-intro"><p class="vs-intro__label">Scroll · Velocity skew</p><h1 class="vs-intro__title">Momentum</h1></section>
<section class="vs">
<div class="vs__grid">
<h2 class="vs__item vs__word">Velocity</h2>
<article class="vs__item vs__tile" style="--h:196"><p class="vs__cap"><span>01</span>Harbour</p></article>
<article class="vs__item vs__tile" style="--h:28"><p class="vs__cap"><span>02</span>Kiln</p></article>
<article class="vs__item vs__tile" style="--h:262"><p class="vs__cap"><span>03</span>Meridian</p></article>
<h2 class="vs__item vs__word vs__word--right">Inertia</h2>
<!-- …3 tiles, word, 3 tiles, word, 3 tiles: 12 tiles + 4 words total -->
</div>
</section>
<section class="vs-outro"><p>Everything has returned to zero.</p></section>
<aside class="vs__readout" aria-live="off">
<span>velocity <b class="vs__read-v">+0000</b> px/s</span>
<span>skew <b class="vs__read-s">+0.0</b>°</span>
<span class="vs__readout-bar"><i class="vs__readout-fill"></i></span>
</aside>
```
(Tiles are CSS gradients driven by a `--h` hue so no image assets are needed; swap in `<img>`s in production.)
**CSS requirements:**
- `.vs-intro`, `.vs-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center;`
- `.vs`: `padding: 4vw 6vw 10vw;`
- `.vs__grid`: `display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(1rem, 2.5vw, 2.5rem); align-items: center;` → `repeat(2, 1fr)` under 768px.
- `.vs__item`: `will-change: transform; transform-origin: 50% 50%;` (centre origin so shearing doesn't look like a shift).
- `.vs__word`: `grid-column: 1 / -1;` display serif, `font-weight: 300; font-size: clamp(3.5rem, 13vw, 13rem); letter-spacing: -0.04em; line-height: .85; padding: .25em 0 .1em;` `.vs__word--right { text-align: right; font-style: italic; font-weight: 200; }`
- `.vs__tile`: `position: relative; aspect-ratio: 4 / 5; border-radius: 10px; overflow: hidden; isolation: isolate;` background (three layers):
`radial-gradient(120% 90% at 20% 15%, hsl(var(--h) 55% 60% / .95), transparent 60%)`,
`radial-gradient(90% 70% at 85% 92%, hsl(calc(var(--h) + 40) 60% 32% / .9), transparent 65%)`,
`linear-gradient(165deg, hsl(var(--h) 30% 20%), hsl(calc(var(--h) + 20) 40% 7%))`.
- `.vs__tile::before`: full-cover grain — an inline SVG data-URI `feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="2"` tile (180×180), `opacity: .45; mix-blend-mode: overlay;`
- `.vs__tile::after`: `linear-gradient(to top, rgba(0,0,0,.55), transparent 45%)` so captions stay legible.
- `.vs__cap`: `position: absolute; left: 1.1rem; bottom: 1rem;` mono 11px uppercase `letter-spacing: .12em; color: rgba(255,255,255,.85);` index span at `opacity: .55`.
- `.vs__readout`: `position: fixed; right: 1.5rem; bottom: 1.5rem; z-index: 5; display: grid; gap: .4rem; padding: .8rem 1rem; border: 1px solid var(--line); border-radius: 8px; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px);` mono 11px uppercase muted, `pointer-events: none;` numbers `font-variant-numeric: tabular-nums; color: var(--text)`.
- `.vs__readout-bar`: `width: 140px; height: 1px; background: var(--line, rgba(255,255,255,.08)); overflow: hidden;` `.vs__readout-fill { height: 100%; width: 100%; background: var(--accent, #d4ff3a); transform-origin: left; transform: scaleX(0); }`
- Reduced motion: `.vs__readout { display: none; }`, `.vs__item { will-change: auto; }`.
**Animation spec (exact values):**
Constants: `MAX_SKEW = 8` (deg), `SKEW_DIVISOR = 350` (px/s per degree), `MAX_SCALE_DROP = 0.06`, `WORD_SHIFT = 48` (px), `RETURN = 0.8` (s), `RETURN_EASE = 'power3.out'`.
1. Setters (created once): `skewSet = gsap.quickSetter(items, 'skewY', 'deg')`, `scaleSet = gsap.quickSetter(items, 'scale')`, `wordSet = gsap.quickSetter(words, 'x', 'px')`, `fillSet = gsap.quickSetter('.vs__readout-fill', 'scaleX')`.
2. A proxy `state = { v: 0 }` holds the *smoothed* velocity. `render()` derives everything from it:
- `skew = clamp(-maxSkew, maxSkew, -state.v / 350)` → `skewSet(skew)` (scrolling down = positive velocity = negative skew; the grid leans "back").
- `scaleSet(1 - Math.min(|v| / 8000, 0.06))` → 1 at rest, 0.94 at ≥ 480 px/s… (full drop at 480 px/s × 8000/480 — i.e. at |v| ≥ 480 the drop saturates).
- `wordSet(clamp(-48, 48, v / 80))`.
- `fillSet(|skew| / 8)`; readout text: velocity as `±dddd` (clamped to ±9999), skew as `±d.d`.
3. Smoothing: `velTo = gsap.quickTo(state, 'v', { duration: 0.8, ease: 'power3.out', onUpdate: render })`. Every call retargets the same tween (no new tweens per event).
4. Return to zero: `settle = gsap.delayedCall(0.15, () => velTo(0)).pause()`.
5. Velocity source:
```js
ScrollTrigger.create({ start: 0, end: 'max', markers: false,
onUpdate(self) { velTo(self.getVelocity()); settle.restart(true); } });
```
`onUpdate` fires on every scroll frame; when scrolling stops it stops firing, the 0.15s `settle` elapses and `velTo(0)` eases the proxy — and therefore skew, scale, shift and readout — back to rest over 0.8s with `power3.out`.
6. Intro: `gsap.from('.vs-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`.
7. `document.fonts.ready.then(() => ScrollTrigger.refresh())`.
**Interaction:** Purely scroll-driven; direction of the lean follows scroll direction, magnitude follows speed. No hover or click behaviour. Keyboard/space-bar scrolling produces short bursts that lean and settle.
**Responsive behavior:** `gsap.matchMedia()` with `'(max-width: 767px), (hover: none) and (pointer: coarse)'` lowers the clamp to `maxSkew = 8 × 5/8` (5° at the default — touch flicks are fast and cards are small) and restores 8 on revert. Grid drops to two columns under 768px; the readout moves to `right: 1rem; bottom: 1rem` with a 100px bar. Word size is fluid via `clamp(3.5rem, 13vw, 13rem)`.
**Accessibility:** With `prefers-reduced-motion: reduce` do not create the ScrollTrigger or the setters — the grid is static and upright and the readout is hidden. The readout is `aria-live="off"` so screen readers are not spammed by velocity numbers. Tiles are `<article>`s with visible captions; words are `<h2>`s.
**Performance rules:** Only `transform` is written (`skewY`, `scale`, `x`, `scaleX`), via `quickSetter` — never `gsap.to` inside `onUpdate`. One `quickTo` tween does all the easing. `will-change: transform` on `.vs__item` (removed under reduced motion). Everything is created inside `gsap.context()` and reverted on unmount (`ctx.revert()` kills the ScrollTrigger, the quickTo tween and the delayedCall).
**Customization knobs:**
- `MAX_SKEW` (8) and the mobile clamp (5/8 of it).
- `SKEW_DIVISOR` (350) — lower = more sensitive.
- `RETURN` (0.8) and its ease (`power3.out`; `power3.out` is the soft default, `expo.out` for a snappier return, `elastic.out(1, 0.6)` for a wobble).
- `MAX_SCALE_DROP` (0.06) and `WORD_SHIFT` (48) — set to 0 to disable.
- Readout bar colour (`#d4ff3a`).
- Tile hues: the `--h` inline variable per tile.
**Acceptance criteria:**
- [ ] Grid is upright at rest; a fast scroll leans every item (same angle) and shrinks it slightly; opposite direction leans the other way.
- [ ] Skew never exceeds ±8° on desktop / 5/8 of that on phones no matter how fast the scroll.
- [ ] After scrolling stops, items return to 0° within ~0.8s (+0.15s idle delay) with no snap; the readout returns to +0000 / +0.0.
- [ ] Readout bar fills proportionally to |skew| and is empty at rest.
- [ ] Reduced-motion users see a static grid and no readout.
- [ ] No console errors; no tweens created per scroll event.
Source
Full source code
<section class="vs-intro">
<p class="vs-intro__label">Scroll · Velocity skew</p>
<h1 class="vs-intro__title">Momentum</h1>
<p class="vs-intro__copy">Scroll fast and the page leans with you. Stop, and everything settles back upright.</p>
</section>
<section class="vs">
<div class="vs__grid">
<h2 class="vs__item vs__word">Velocity</h2>
<article class="vs__item vs__tile" style="--h:196"><p class="vs__cap"><span>01</span>Harbour</p></article>
<article class="vs__item vs__tile" style="--h:28"><p class="vs__cap"><span>02</span>Kiln</p></article>
<article class="vs__item vs__tile" style="--h:262"><p class="vs__cap"><span>03</span>Meridian</p></article>
<h2 class="vs__item vs__word vs__word--right">Inertia</h2>
<article class="vs__item vs__tile" style="--h:160"><p class="vs__cap"><span>04</span>Tidal</p></article>
<article class="vs__item vs__tile" style="--h:92"><p class="vs__cap"><span>05</span>Grove</p></article>
<article class="vs__item vs__tile" style="--h:8"><p class="vs__cap"><span>06</span>Cinder</p></article>
<h2 class="vs__item vs__word">Drift</h2>
<article class="vs__item vs__tile" style="--h:42"><p class="vs__cap"><span>07</span>Lantern</p></article>
<article class="vs__item vs__tile" style="--h:214"><p class="vs__cap"><span>08</span>Quarry</p></article>
<article class="vs__item vs__tile" style="--h:330"><p class="vs__cap"><span>09</span>Sable</p></article>
<h2 class="vs__item vs__word vs__word--right">Settle</h2>
<article class="vs__item vs__tile" style="--h:124"><p class="vs__cap"><span>10</span>Vale</p></article>
<article class="vs__item vs__tile" style="--h:240"><p class="vs__cap"><span>11</span>Nocturne</p></article>
<article class="vs__item vs__tile" style="--h:70"><p class="vs__cap"><span>12</span>Ridge</p></article>
</div>
</section>
<section class="vs-outro">
<p class="vs-outro__text">Everything has returned to zero. The page continues normally.</p>
</section>
<aside class="vs__readout" aria-live="off">
<span class="vs__readout-row">velocity <b class="vs__read-v">+0000</b> px/s</span>
<span class="vs__readout-row">skew <b class="vs__read-s">+0.0</b>°</span>
<span class="vs__readout-bar"><i class="vs__readout-fill"></i></span>
</aside>
.vs-intro, .vs-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center; }
.vs-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.vs-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(3rem, 10vw, 10rem); letter-spacing: -0.04em; line-height: .9; font-variation-settings: 'opsz' 144, 'SOFT' 20; }
.vs-intro__copy { max-width: 40ch; margin: 2rem auto 0; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.1rem); line-height: 1.5; }
.vs-outro__text { color: var(--muted); max-width: 40ch; }
.vs { padding: 4vw 6vw 10vw; }
.vs__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(1rem, 2.5vw, 2.5rem); align-items: center; }
.vs__item { will-change: transform; transform-origin: 50% 50%; }
.vs__word { grid-column: 1 / -1; font-family: var(--font-display); font-weight: 300; font-size: clamp(3.5rem, 13vw, 13rem); letter-spacing: -0.04em; line-height: .85; padding: .25em 0 .1em; font-variation-settings: 'opsz' 144, 'SOFT' 40; }
.vs__word--right { text-align: right; font-style: italic; font-weight: 200; }
.vs__tile {
position: relative; aspect-ratio: 4 / 5; border-radius: 10px; overflow: hidden; isolation: isolate;
background:
radial-gradient(120% 90% at 20% 15%, hsl(var(--h) 55% 60% / .95), transparent 60%),
radial-gradient(90% 70% at 85% 92%, hsl(calc(var(--h) + 40) 60% 32% / .9), transparent 65%),
linear-gradient(165deg, hsl(var(--h) 30% 20%), hsl(calc(var(--h) + 20) 40% 7%));
}
.vs__tile::before { content: ''; position: absolute; inset: 0; z-index: 1; opacity: .45; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
.vs__tile::after { content: ''; position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, rgba(0,0,0,.55), transparent 45%); }
.vs__cap { position: absolute; left: 1.1rem; bottom: 1rem; z-index: 2; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: rgba(255,255,255,.85); display: flex; gap: .9em; }
.vs__cap span { opacity: .55; }
.vs__readout { position: fixed; right: 1.5rem; bottom: 1.5rem; z-index: 5; display: grid; gap: .4rem; padding: .8rem 1rem; border: 1px solid var(--line); border-radius: 8px; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); pointer-events: none; }
.vs__readout b { font-weight: 500; color: var(--text); font-variant-numeric: tabular-nums; }
.vs__readout-bar { display: block; width: 140px; height: 1px; background: var(--line); overflow: hidden; }
.vs__readout-fill { display: block; height: 100%; width: 100%; background: var(--knob-accent, var(--accent)); transform-origin: left; transform: scaleX(0); }
@media (max-width: 767px) {
.vs__grid { grid-template-columns: repeat(2, 1fr); }
.vs__readout { right: 1rem; bottom: 1rem; padding: .6rem .8rem; }
.vs__readout-bar { width: 100px; }
}
@media (prefers-reduced-motion: reduce) {
.vs__item { will-change: auto; }
.vs__readout { display: none; }
}
// Scroll Velocity Skew — the grid leans with scroll speed and settles back to zero.
gsap.registerPlugin(ScrollTrigger);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { maxSkew: 8, sensitivity: 350, settle: 0.8, ease: 'power3.out', wordShift: 48, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const MAX_SCALE_DROP = 0.06; // items shrink to 0.94 at full speed
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const poster = new URLSearchParams(location.search).get('thumb') === '1'; // library poster: hold the peak a little longer
const items = gsap.utils.toArray('.vs__item');
const words = gsap.utils.toArray('.vs__word');
const readout = document.querySelector('.vs__readout');
const readV = document.querySelector('.vs__read-v');
const readS = document.querySelector('.vs__read-s');
const fill = document.querySelector('.vs__readout-fill');
const fmt = (n, d = 0) => (n < 0 ? '−' : '+') + Math.abs(n).toFixed(d).padStart(d ? 3 : 4, '0');
let ctx;
function build() {
ctx?.revert();
const MAX_SKEW = K('maxSkew'); // degrees, clamped
const SKEW_DIVISOR = K('sensitivity'); // px/s of scroll velocity per degree of skew
const RETURN = K('settle'); // seconds to settle back
const EASE = K('ease');
const WORD_SHIFT = K('wordShift'); // px the big words drift sideways at full speed
readout.style.setProperty('--knob-accent', K('accent'));
ctx = gsap.context(() => {
gsap.from('.vs-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) return; // static grid, no readout
// Reset whatever a previous build's setters left behind.
gsap.set(items, { skewY: 0, scale: 1, x: 0 });
gsap.set(fill, { scaleX: 0 });
readV.textContent = fmt(0); readS.textContent = fmt(0, 1);
let maxSkew = MAX_SKEW;
const state = { v: 0 };
const clamp = gsap.utils.clamp;
const skewSet = gsap.quickSetter(items, 'skewY', 'deg');
const scaleSet = gsap.quickSetter(items, 'scale');
const wordSet = gsap.quickSetter(words, 'x', 'px');
const fillSet = gsap.quickSetter(fill, 'scaleX');
const render = () => {
const v = state.v;
const skew = clamp(-maxSkew, maxSkew, -v / SKEW_DIVISOR);
skewSet(skew);
scaleSet(1 - Math.min(Math.abs(v) / 8000, MAX_SCALE_DROP));
wordSet(clamp(-WORD_SHIFT, WORD_SHIFT, v / 80));
fillSet(Math.abs(skew) / MAX_SKEW);
readV.textContent = fmt(clamp(-9999, 9999, v));
readS.textContent = fmt(skew, 1);
};
// Smoothed velocity: every scroll update retargets the quickTo, so the easing never restarts abruptly.
const velTo = gsap.quickTo(state, 'v', { duration: RETURN, ease: EASE, onUpdate: render });
const settle = gsap.delayedCall(poster ? 3 : 0.15, () => velTo(0)).pause();
ScrollTrigger.create({
start: 0, end: 'max', markers: false,
onUpdate(self) {
const v = self.getVelocity();
if (poster && Math.abs(v) < Math.abs(state.v)) return; // poster: keep the peak so the thumbnail shows the lean
velTo(v); settle.restart(true);
},
});
const mm = gsap.matchMedia();
mm.add('(max-width: 767px), (hover: none) and (pointer: coarse)', () => {
maxSkew = MAX_SKEW * 0.625; // smaller cards + touch flicks: tame the lean (5° at the default 8°)
return () => { maxSkew = MAX_SKEW; };
});
document.fonts.ready.then(() => ScrollTrigger.refresh());
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());