← Scroll

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

  1. Paste the snippet just before </head>.
  2. Add data-scroll-rotate to 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-speedDegrees it turns per 100 px of scrolling. Default 30; negative turns the other way.
data-angleIts starting angle, in degrees. Default 0.
data-smoothHow 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.

1 · Snippet
<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>
2 · Your HTML
<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">