Scroll Velocity Skew
Content leans with the speed of the scroll, as if dragged along, and springs back upright when the scrolling stops. A gentle scroll barely tilts it; a flick leans it right over.
Scroll inside ↓
Scroll
faster
and it
leans
into
the speed
How to use it
- Paste the snippet just before
</head>. - Add
data-scroll-velocity-skewto each element that should lean: headings, images, rows of cards.
Options
Add any of these attributes to the same element.
data-max | The furthest it leans, in degrees. Default 10. |
data-strength | How strongly speed turns into lean. Default 1; try 2 for a livelier feel. |
data-stretch | Also stretch a little in the direction of travel. |
Everything in the same scrolling area leans together. Works inside scrolling boxes too.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Scroll Velocity Skew: content leans with the speed of the scroll, as if
// dragged along, and springs back upright when the scrolling stops. A gentle
// scroll barely tilts it; a flick leans it right over.
// Usage: <div data-scroll-velocity-skew>…</div> (each element that leans)
// Optional: data-max (largest lean in degrees, default 10), data-strength
// (how strongly speed turns into lean, default 1), data-stretch (also
// stretch a little in the direction of travel).
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { max: 10, strength: 1 };
function scroller(el) {
for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
var o = getComputedStyle(p).overflowY;
if ((o === 'auto' || o === 'scroll') && p.scrollHeight > p.clientHeight) return p;
}
return null;
}
// Everything that scrolls together shares one speed reading.
var groups = [];
function group(box) {
for (var i = 0; i < groups.length; i++) if (groups[i].box === box) return groups[i];
var g = { box: box, items: [], pos: null, time: 0, rest: null };
var target = box || window;
function pos() { return box ? box.scrollTop : window.scrollY; }
target.addEventListener('scroll', function () {
var now = performance.now(), y = pos();
if (g.pos !== null && now > g.time) {
// px per ms, smoothed so one jumpy frame doesn't jerk it.
var v = (y - g.pos) / Math.max(8, now - g.time);
g.v = g.v === undefined ? v : g.v * 0.5 + v * 0.5;
lean(g, g.v);
}
g.pos = y;
g.time = now;
// No scroll for a moment: stand back up.
clearTimeout(g.rest);
g.rest = setTimeout(function () { g.v = 0; lean(g, 0); }, 90);
}, { passive: true });
groups.push(g);
return g;
}
function lean(g, v) {
g.items.forEach(function (it) {
var deg = Math.max(-it.max, Math.min(it.max, -v * 5 * it.strength));
it.anim.skewY(deg);
if (it.stretch) it.anim.scaleY(1 + Math.min(0.12, Math.abs(deg) / it.max * 0.08));
});
}
function init() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
document.querySelectorAll('[data-scroll-velocity-skew]').forEach(function (el) {
if (el._svs) return;
el._svs = true;
var d = el.dataset;
var stretch = d.stretch !== undefined && d.stretch !== 'false';
var props = { skewY: { unit: 'deg', duration: 500 }, ease: 'out(3)' };
if (stretch) props.scaleY = 500;
group(scroller(el)).items.push({
anim: anime.createAnimatable(el, props),
max: d.max !== undefined ? +d.max : DEFAULTS.max,
strength: d.strength !== undefined ? +d.strength : DEFAULTS.strength,
stretch: stretch
});
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h2 data-scroll-velocity-skew>Move fast</h2>
<div class="gallery" data-scroll-velocity-skew>…</div>