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
- Paste the snippet just before
</head>. - Add
data-product-spin-360to 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-sensitivity | Pixels of drag per photo. Default: the width over the number of photos, so a drag across is a full turn. |
data-autoplay | Photos a second to turn by itself until someone grabs it. Off by default; on its own it means 8. |
data-resume | Milliseconds after letting go before it turns by itself again. Default never. |
data-reverse | Go 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.
<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><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>