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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/scroll-velocity-skew.jsonPrompt
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 saturation —
maxVelocityis 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 atargetthat listener sits onwindowin 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
transformproperty. - 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
matchMediasubscriptions, so switching either mid-session takes effect without a reload.
Parallax Layers
A depth wrapper — each child declares a depth and translates at that rate as the container passes the viewport, with an optional pointer nudge, one rAF and no re-renders.
Logo Morph
One SVG shape that travels between several logo or icon paths — normalised at runtime so the points pair up, and cross-fading instead of tearing when they cannot.