← Cards & images

360° Product Spin

A product you can turn all the way round. Drag or swipe sideways and it steps through photos taken from every angle; let go with a flick and it spins on before slowing to a stop. Until someone grabs it, it can turn slowly by itself.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-product-spin-360 to a box holding the photos, in order going round. The first sets the size.

Options

Add any of these attributes to the same element.

data-sensitivityPixels of drag per photo. Default: the width over the number of photos, so a drag across is a full turn.
data-autoplayPhotos a second to turn by itself until someone grabs it. Off by default; on its own it means 8.
data-resumeMilliseconds after letting go before it turns by itself again. Default never.
data-reverseGo back through the photos when dragging right.
data-hint="false"Leave out the “Drag to spin” badge.

24 to 36 photos make a smooth turn. It’s a slider for keyboards and screen readers: the arrow keys step round and the angle is read out. Upright scrolling still works on phones. From code: animacious.spin(element, 6) shows the seventh photo.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // 360° Product Spin: a stack of photos of a product taken all the way
    // round it. Drag sideways (or swipe) to turn it; let go and it carries on
    // a little before stopping. Until someone grabs it, it can turn slowly by
    // itself, and the arrow keys step through the angles.
    // Usage: <div data-product-spin-360 aria-label="Our mug, all the way round">
    //          <img src="mug-00.jpg" alt=""> <img src="mug-01.jpg" alt=""> … (in order, going round)
    //        </div>
    // Optional: data-sensitivity (px of drag per photo, default: the width
    // divided by the number of photos, so a full drag across is a full turn),
    // data-autoplay (photos a second it turns by itself until someone grabs
    // it, default off; "true" means 8), data-resume (ms after letting go
    // before it turns by itself again; default never), data-reverse (turn
    // the other way when dragging), data-hint="false" (no "drag to spin" badge).
    // Script use: animacious.spin(el, index).
    var DEFAULTS = { autoplay: 8 };

    var css =
      '.psn{position:relative;touch-action:pan-y;cursor:grab;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent}' +
      '.psn.psn-drag{cursor:grabbing}' +
      '.psn>.psn-f{display:block;width:100%;height:auto;visibility:hidden;pointer-events:none;-webkit-user-drag:none}' +
      '.psn>.psn-f:not(:first-child){position:absolute;left:0;top:0;height:100%;object-fit:contain}' +
      '.psn>.psn-f.psn-on{visibility:visible}' +
      '.psn-hint{position:absolute;left:50%;bottom:8px;display:flex;align-items:center;gap:6px;padding:5px 10px 5px 8px;border-radius:999px;background:rgba(20,20,30,.72);color:#fff;font:600 12px/1 system-ui,sans-serif;white-space:nowrap;transform:translateX(-50%);pointer-events:none}' +
      '.psn-hint svg{width:16px;height:16px}' +
      '.psn-track{position:absolute;left:12%;right:12%;bottom:2px;height:3px;border-radius:2px;background:rgba(127,127,127,.2);pointer-events:none}' +
      '.psn-track>i{position:absolute;top:0;width:14%;height:100%;border-radius:2px;background:currentColor;opacity:.55}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var ARROWS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 0 1-15.5 6.2"/><path d="M3 12A9 9 0 0 1 18.5 5.8"/><path d="M18.8 2v4h-4M5.2 22v-4h4"/></svg>';

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var frames = Array.prototype.slice.call(el.children);
      if (frames.length < 2) return;
      el.classList.add('psn');
      frames.forEach(function (f) { f.classList.add('psn-f'); f.setAttribute('aria-hidden', 'true'); });
      var thumb = !!el.closest('[aria-hidden="true"]');
      var n = frames.length;
      var s = el._psnS = { frames: frames, n: n, pos: 0, shown: -1, v: 0, drag: null, raf: 0, last: 0, visible: false, auto: 0, idleAt: 0, grabbed: false };
      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) s.auto = +d.autoplay || DEFAULTS.autoplay;

      if (!thumb) {
        el.setAttribute('role', 'slider');
        el.setAttribute('aria-valuemin', '0');
        el.setAttribute('aria-valuemax', '359');
        if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Product, all the way round');
        if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
        el.addEventListener('keydown', function (e) {
          var step = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -1 : 0;
          if (!step) return;
          e.preventDefault();
          grab(el);
          s.v = 0;
          s.pos = Math.round(s.pos) + step;
          show(el);
        });
        if (d.hint !== 'false') {
          s.hint = document.createElement('span');
          s.hint.className = 'psn-hint';
          s.hint.setAttribute('aria-hidden', 'true');
          s.hint.innerHTML = ARROWS + 'Drag to spin';
          el.appendChild(s.hint);
        }
      }
      var track = document.createElement('span');
      track.className = 'psn-track';
      track.setAttribute('aria-hidden', 'true');
      track.appendChild(document.createElement('i'));
      el.appendChild(track);
      s.knob = track.firstChild;
      show(el);

      el.addEventListener('pointerdown', function (e) {
        if (e.button) return;
        grab(el);
        s.drag = { x: e.clientX, t: performance.now(), pos: s.pos };
        s.v = 0;
        el.classList.add('psn-drag');
        try { el.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
      });
      el.addEventListener('pointermove', function (e) {
        if (!s.drag) return;
        var now = performance.now(), dx = e.clientX - s.drag.x;
        var per = +d.sensitivity || Math.max(4, el.clientWidth / n);
        // Dragging right moves on through the photos; data-reverse goes back through them instead.
        var dp = (d.reverse !== undefined ? -1 : 1) * dx / per;
        s.v = s.v * 0.5 + (dp / Math.max(1, now - s.drag.t)) * 0.5;
        s.pos += dp;
        s.drag.x = e.clientX;
        s.drag.t = now;
        show(el);
      });
      function end() {
        if (!s.drag) return;
        if (performance.now() - s.drag.t > 80 || reduced()) s.v = 0;
        s.drag = null;
        s.idleAt = performance.now();
        el.classList.remove('psn-drag');
        kick(el);
      }
      el.addEventListener('pointerup', end);
      el.addEventListener('pointercancel', end);
      el.addEventListener('dragstart', function (e) { e.preventDefault(); });

      new IntersectionObserver(function (en) {
        s.visible = en[0].isIntersecting;
        if (s.visible) kick(el);
      }, { threshold: 0.2 }).observe(el);
    }

    function grab(el) {
      var s = el._psnS;
      if (s.grabbed) return;
      s.grabbed = true;
      if (s.hint) anime.animate(s.hint, { opacity: 0, y: 6, duration: 300, ease: 'out(2)' });
    }

    function show(el) {
      var s = el._psnS;
      var i = ((Math.round(s.pos) % s.n) + s.n) % s.n;
      var frac = ((s.pos % s.n) + s.n) % s.n / s.n;
      s.knob.style.left = (frac * 86).toFixed(2) + '%';
      if (i === s.shown) return;
      if (s.shown >= 0) s.frames[s.shown].classList.remove('psn-on');
      s.frames[i].classList.add('psn-on');
      s.shown = i;
      if (el.getAttribute('role') === 'slider') {
        var deg = Math.round(i * 360 / s.n);
        el.setAttribute('aria-valuenow', deg);
        el.setAttribute('aria-valuetext', deg + ' degrees');
      }
    }

    function kick(el) {
      var s = el._psnS;
      if (s.raf || !s.visible) return;
      s.last = performance.now();
      s.raf = requestAnimationFrame(function tick(now) {
        s.raf = 0;
        if (!s.visible) return;
        var dt = Math.min(50, now - s.last), d = el.dataset;
        s.last = now;
        var busy = false;
        if (!s.drag) {
          if (Math.abs(s.v) > 0.0004) {
            s.pos += s.v * dt;
            s.v *= Math.pow(0.994, dt);
            busy = true;
          } else {
            s.v = 0;
            var resume = d.resume !== undefined ? +d.resume : Infinity;
            if (s.auto && (!s.grabbed || now - s.idleAt > resume)) {
              s.pos += s.auto * dt / 1000;
              busy = true;
            } else if (s.auto && resume !== Infinity) busy = true;
          }
          show(el);
        }
        if (busy) s.raf = requestAnimationFrame(tick);
      });
    }

    function init() {
      document.querySelectorAll('[data-product-spin-360]').forEach(function (el) {
        if (el._psn) return;
        el._psn = true;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.spin = function (el, i) { var s = el._psnS; if (s) { grab(el); s.v = 0; s.pos = i; show(el); } };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-product-spin-360 data-autoplay style="width: 320px" aria-label="Our mug, all the way round">
  <img src="mug-01.jpg" alt="">
  <img src="mug-02.jpg" alt="">
  <!-- … one photo per angle -->
  <img src="mug-24.jpg" alt="">
</div>