Scroll Rotate
Badges, gears and logos turn as the page scrolls: forwards as you scroll down, backwards as you scroll up, coasting to a stop when you do. Give meshing gears speeds in the ratio of their teeth and they turn together.
Scroll inside ↓
How to use it
- Paste the snippet just before
</head>. - Add
data-scroll-rotateto an image, a badge, an icon or a shape inside an SVG. It turns about its own centre.
Options
Add any of these attributes to the same element.
data-speed | Degrees it turns per 100 px of scrolling. Default 30; negative turns the other way. |
data-angle | Its starting angle, in degrees. Default 0. |
data-smooth | How quickly it catches up with the scroll, from 0 to 1. Default 0.12; 1 follows it exactly. |
For two meshing gears, set speeds in the ratio of their teeth with opposite signs: here the 12-tooth gear has 30 and the 8-tooth one -45. With reduced motion everything stays at its starting angle. Works inside scrolling boxes too.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Scroll Rotate: badges, gears, logos or anything else turn as the page
// scrolls: forwards as you scroll down, backwards as you scroll up, with a
// touch of inertia so they coast to a stop.
// Usage: <img data-scroll-rotate src="badge.svg" alt="…">
// Works on HTML elements and on shapes inside an SVG (which turn about
// their own centre).
// Optional: data-speed (degrees per 100px scrolled, default 30; negative
// turns the other way), data-angle (starting angle in degrees, default 0),
// data-smooth (how quickly it catches up with the scroll, 0 to 1, default
// 0.12; 1 follows it exactly).
// Tip: for meshing gears, set their speeds in the ratio of their teeth
// with opposite signs, e.g. 30 for a 12-tooth gear and -45 for an 8-tooth one.
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { speed: 30, angle: 0, smooth: 0.12 };
var css = '.srot{transform-box:fill-box;transform-origin:50% 50%;will-change:transform}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }
function scroller(el) {
for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
if (p instanceof SVGElement) continue;
var o = getComputedStyle(p).overflowY;
if (o === 'auto' || o === 'scroll') return p;
}
return null;
}
function setup(el) {
var d = el.dataset;
var speed = num(d.speed, DEFAULTS.speed);
var start = num(d.angle, DEFAULTS.angle);
var smooth = Math.max(0.01, Math.min(1, num(d.smooth, DEFAULTS.smooth)));
var box = scroller(el);
var target = 0, shown = 0, running = false, visible = true;
el.classList.add('srot');
function getY() { return box ? box.scrollTop : window.pageYOffset; }
function paint() { el.style.transform = 'rotate(' + (start + shown).toFixed(2) + 'deg)'; }
function tick() {
shown += (target - shown) * smooth;
if (Math.abs(target - shown) < 0.02) shown = target;
paint();
if (shown !== target && visible) requestAnimationFrame(tick);
else running = false;
}
function onScroll() {
target = getY() * speed / 100;
if (!visible) { shown = target; return; }
if (running) return;
running = true;
requestAnimationFrame(tick);
}
target = shown = getY() * speed / 100;
paint();
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) { shown = target; paint(); }
}).observe(el);
(box || window).addEventListener('scroll', onScroll, { passive: true });
}
function init() {
document.querySelectorAll('[data-scroll-rotate]').forEach(function (el) {
if (el._srot) return;
el._srot = true;
if (reduced()) { // no motion: just its starting angle
var a = num(el.dataset.angle, 0);
if (a) { el.style.transformBox = 'fill-box'; el.style.transformOrigin = '50% 50%'; el.style.transform = 'rotate(' + a + 'deg)'; }
return;
}
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><img data-scroll-rotate src="badge.svg" alt="Since 2015">
<!-- slower, the other way: -->
<img data-scroll-rotate data-speed="-15" src="logo.svg" alt="Acme">