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.

Loading

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.

prompt.md · Scroll Velocity Skew
### 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>