Display

Scroll Velocity Skew

A wrapper that leans and slightly scales its content in proportion to scroll velocity, then eases back to neutral the moment scrolling stops.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
const COARSE_POINTER = "(pointer: coarse)"

/**
 * A finger flings far harder than a wheel and keeps gliding after it lifts, so
 * full-strength lean reads as jitter on a phone. Coarse pointers keep the
 * effect at a calmer amplitude instead of losing it entirely.
 */
const COARSE_AMPLITUDE = 0.55

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/scroll-velocity-skew.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ScrollVelocitySkew" component — a generic
wrapper whose children lean (skew) and slightly scale in proportion to how fast
the page, or a given scroll container, is currently scrolling, and ease back to
neutral once it stops. No animation library.

Contract
- Export a forwardRef<HTMLDivElement> component extending
  React.HTMLAttributes<HTMLDivElement>; children render untouched inside one
  root <div>, which is the element that gets transformed.
- Props: axis = "y" | "x" (default "y" — "y" measures vertical scroll and
  applies skewY, "x" measures horizontal scroll and applies skewX),
  maxSkew (degrees at clamped top speed, default 6; negative flips the lean),
  maxScale (the scale factor actually reached at top speed, NOT a delta —
  default 0.96 so content recedes ~4% at full fling; 1 disables scaling,
  > 1 swells instead), damping (fraction of the remaining distance the
  rendered lean covers per 60fps frame, clamped to (0, 1], default 0.14),
  maxVelocity (scroll speed in px/second that maps to the full effect,
  default 2400), target (RefObject to a scroll container; omit to measure
  window), disabled (boolean — force the neutral wrapper).

Behavior
- One passive scroll listener whose ONLY job is to start the rAF loop if it
  isn't running — no measuring, no style writes per event, so an event burst
  collapses into at most one sample per frame. Bind it to window, in the
  CAPTURE phase whenever target is given: scroll events don't bubble, but the
  capture phase runs down from window, so a single listener hears the page and
  whichever element the ref points at, including a panel that only mounts
  after the effect ran.
- Never freeze the source at effect setup. A RefObject's identity never
  changes, so `const el = target.current` inside the effect would bind to
  window forever whenever the container is still null on that one run.
  Re-resolve `target.current ?? window` on every sample instead; when the
  source changed since the last sample, treat that sample as a new baseline
  (delta = 0), because the page and a container count from unrelated origins.
- Each frame: read scrollTop/scrollLeft (or window.scrollY/scrollX), take
  delta against the previous sample and dt against the previous timestamp
  (cap dt at ~100ms so a backgrounded tab doesn't return with one enormous
  step). velocity = delta * 1000 / dt in px/second; the normalized goal is
  clamp(velocity / maxVelocity, -1, 1) — a trackpad fling saturates at 1
  instead of exploding into an unreadable shear.
- The rendered lean approaches that goal exponentially:
  lean += (goal - lean) * (1 - (1 - damping) ** (dt / 16.667)), so the same
  damping value feels identical at 60Hz and 120Hz. Because the goal falls to
  0 as soon as scrolling stops, "ease back to neutral" needs no separate
  return animation.
- Write transform straight onto the root node — skewY/skewX(lean * maxSkew)
  plus scale(1 + (maxScale - 1) * |lean|). React never re-renders during a
  scroll; state would be both slower and pointless here.
- The loop is demand-driven: when delta is 0 and |lean| has fallen under a
  small epsilon, clear transform + will-change and stop scheduling frames.
  Set will-change on the first frame that actually writes a transform, NOT in
  the listener — the capture listener also wakes the loop for scrolls this
  wrapper doesn't care about, and promoting then demoting a layer on each of
  those costs more than the hint buys. A resting node never carries it.
- Document the overflow caveat: a skewed node enlarges the scrollable overflow
  of whatever scrolls it — roughly width / 2 * tan(maxSkew) of extra
  scrollHeight (height / 2 for axis="x") for as long as the lean lasts. When
  the wrapper sits inside the very container it measures (the usual `target`
  case), that container's scroll range breathes on every fling and a position
  pinned at the far end is clamped back the frame the lean releases. Say so on
  maxSkew, keep demo values moderate, and mention the alternative: skew a
  wrapper around the scroller rather than its content.
- Environment gates, both read through useSyncExternalStore over matchMedia
  (server snapshot false, listeners removed on unmount, so an OS-level change
  mid-session takes effect immediately): prefers-reduced-motion turns the
  whole thing off — no listener, no loop, children render exactly as passed;
  (pointer: coarse) keeps the effect but multiplies the amplitude by ~0.55,
  because a finger flings far harder than a wheel and keeps gliding after it
  lifts. Nothing listens to pointer events or calls preventDefault, so touch
  scrolling is never intercepted.
- Cleanup on unmount / prop change / disable: remove the listener, cancel the
  pending frame, and reset transform + will-change so the node is left neutral.

Rendering & styling
- The component sets no colour, spacing or radius of its own — it only
  transforms whatever the children already look like; cn() merges the
  consumer's className onto the root and remaining props spread onto it.
  Any surface styling in a demo uses semantic tokens only (bg-card,
  bg-background, border, text-muted-foreground, var(--chart-1..5) for
  decorative artwork).
- Accessibility: nothing is hidden or duplicated, so children keep their own
  semantics and focus order; decorative artwork inside a demo gets
  aria-hidden. Document the caveat that a transformed root becomes the
  containing block for fixed-position descendants.
- SSR-safe: no window/document access during render; every browser read
  happens inside the effect or a store snapshot.

Customization levers
- Loudness: maxSkew 2-4 reads as an editorial nudge, 6-8 as the default
  "this page has weight", 12-16 as a loud showcase — reserve that top band for
  a wrapper that is NOT inside the scroller it measures, per the overflow
  caveat; negate it to lean the other way.
- Weight: maxScale 1 for pure shear, 0.94-0.98 for content that recedes as
  it moves, 1.02-1.06 for content that swells instead. Scaling pivots on the
  wrapper's centre, so wrap sections rather than a page-tall column (or pass
  1) if nudging the far ends would be noticeable.
- Feel: damping ~0.3 snaps back almost immediately (crisp, UI-like), ~0.05
  keeps leaning after you stop (floaty, physical); maxVelocity is the
  saturation point — lower it so ordinary wheel scrolls already reach full
  tilt, raise it so only real flings do.
- Scope: wrap one gallery/section rather than the whole page for a targeted
  effect, and pass target when the content lives inside an overflow-auto
  panel instead of the document scroll.
- Axis: switch to axis="x" for horizontal card strips and carousels; the
  measured scroll axis follows the skew axis automatically.
- Touch amplitude: the coarse-pointer multiplier is one constant — raise it
  for a bolder mobile feel, or set it to 0 to make the effect desktop-only.

Concepts

  • Velocity, not position — every other scroll effect in the library maps where you are to a value; this one maps how fast you are moving, sampled as a per-frame delta over the real frame time, so the same gesture feels the same on a 60Hz and a 120Hz display. Its nearest neighbour, Parallax Layers, is the position half of that pair: it spreads how far you have scrolled across several depth-tagged children, while this one puts how fast onto one root node.
  • Clamped saturationmaxVelocity is the speed that already means "full effect". Anything faster is clipped to it, which is what keeps a violent trackpad fling from turning the section into an unreadable shear.
  • Damped approach, no return animation — the rendered lean chases the live velocity exponentially; the instant you stop scrolling the target becomes 0, so easing back to neutral is the same one line of maths, not a second animation to schedule and cancel.
  • Demand-driven loop — the passive listener only wakes the loop, and the loop kills itself the frame the lean settles, so an idle page schedules zero frames and a resting node carries no will-change. With a target that listener sits on window in the capture phase and the scroll source is re-resolved every frame, so a container that only mounts once its data arrives is picked up instead of being missed forever.
  • Transform-only paint — the transform is written straight onto the DOM node from inside the frame; React never re-renders while you scroll, and the effect stays on the compositor-friendly transform property.
  • Environment gates over feature flags — reduced motion turns the effect fully off (content is never hidden, only never moved) and a coarse pointer scales the amplitude down; both are live matchMedia subscriptions, so switching either mid-session takes effect without a reload.

On This Page