3D Carousel Ring
Cards stand in a ring in 3D space, and the ring turns to bring each one to the front, the ones round the back dimmed and smaller. It turns by itself, and you can drag or swipe it round, use the buttons or arrow keys, or click a card to fetch it.
How to use it
- Paste the snippet just before
</head>. - Add
data-carousel-ringand anaria-labelto a container whose children are the cards, and give the cards a width and height.
Options
Add any of these attributes to the same element.
data-interval | Time between turns. Default 2600 ms. 0 turns only when asked. |
data-tilt | How far the ring leans towards you, in degrees. Default 8. |
data-gap | Space between neighbouring cards round the ring. Default 16 px. |
data-controls="false" | No previous and next buttons. |
The ring’s size comes from the card width and how many cards there are, so it fits itself to the cards. It pauses under the pointer and for a few seconds after someone turns it. From code, animacious.carouselRing(element, 2) turns to a card.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// 3D Carousel Ring: cards stand in a ring in 3D space, and the ring turns
// to bring each card to the front in turn. Drag or swipe to spin it, use
// the buttons or the arrow keys, or click a card to bring it round.
// Usage:
// <div data-carousel-ring aria-label="Gallery">
// <figure><img src="one.jpg" alt="…"></figure>
// <figure>…</figure>
// </div>
// Each child is a card; give the cards a width and a height.
// Optional: data-interval (ms between turns, default 2600; "0" turns only
// when asked), data-tilt (degrees the ring leans towards you, default 8),
// data-gap (space between neighbouring cards, px, default 16),
// data-controls="false" (no buttons).
// Script use: animacious.carouselRing(element, index) turns to a card.
var DEFAULTS = { interval: 2600, tilt: 8, gap: 16, duration: 950 };
var CHEVRON = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>';
var css =
'[data-carousel-ring]{display:grid;justify-items:center;align-content:center;touch-action:pan-y;user-select:none;-webkit-user-select:none}' +
'[data-carousel-ring]:focus-visible{outline:none}' +
'[data-carousel-ring]:focus-visible .crg-front{outline:3px solid #a855f7;outline-offset:4px}' +
'.crg-stage{display:grid;place-items:center;width:100%}' +
'.crg-ring{position:relative;transform-style:preserve-3d}' +
'.crg-ring>*{position:absolute;left:0;top:0;margin:0;cursor:pointer}' +
'.crg-ring>.crg-front{cursor:default}' +
'.crg-nav{display:flex;gap:14px;margin-top:18px}' +
'.crg-nav button{display:grid;place-items:center;width:40px;height:40px;border:0;border-radius:50%;background:#fff;color:#1f1f24;box-shadow:0 4px 14px rgba(15,15,30,.12);cursor:pointer}' +
'.crg-nav button:last-child svg{transform:scaleX(-1)}' +
'.crg-nav button:focus-visible{outline:3px solid #a855f7;outline-offset:2px}' +
'.crg-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var cards = Array.from(el.children);
var n = cards.length;
if (n < 3) return;
var tilt = d.tilt !== undefined ? +d.tilt : DEFAULTS.tilt;
var gap = d.gap !== undefined ? +d.gap : DEFAULTS.gap;
var interval = d.interval !== undefined ? +d.interval : DEFAULTS.interval;
var inThumb = !!el.closest('[aria-hidden="true"]');
var step = 360 / n;
var state = { a: 0 }; // the ring's turn, in degrees
var current = 0, r = 0, tween = null, timer = null, paused = false, idleUntil = 0;
var stage = document.createElement('div');
stage.className = 'crg-stage';
var ring = document.createElement('div');
ring.className = 'crg-ring';
cards.forEach(function (c) { ring.appendChild(c); });
stage.appendChild(ring);
el.appendChild(stage);
var live = null;
if (!inThumb) {
el.setAttribute('role', 'region');
el.setAttribute('aria-roledescription', 'carousel');
if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
cards.forEach(function (c, i) {
c.setAttribute('role', 'group');
c.setAttribute('aria-roledescription', 'slide');
if (!c.getAttribute('aria-label')) c.setAttribute('aria-label', i + 1 + ' of ' + n);
});
if (d.controls !== 'false') {
var nav = document.createElement('div');
nav.className = 'crg-nav';
nav.innerHTML = '<button type="button" aria-label="Previous">' + CHEVRON + '</button><button type="button" aria-label="Next">' + CHEVRON + '</button>';
el.appendChild(nav);
nav.children[0].addEventListener('click', function () { touched(); go(current - 1); });
nav.children[1].addEventListener('click', function () { touched(); go(current + 1); });
}
live = document.createElement('span');
live.className = 'crg-sr';
live.setAttribute('aria-live', 'polite');
el.appendChild(live);
}
// Size the ring so neighbouring cards sit "gap" apart round it.
function measure() {
var w = cards[0].offsetWidth || 120, h = cards[0].offsetHeight || 160;
r = Math.round((w / 2 + gap / 2) / Math.tan(Math.PI / n));
ring.style.width = w + 'px';
ring.style.height = h + 'px';
stage.style.perspective = Math.max(600, r * 3.6) + 'px';
stage.style.height = Math.round(h + 2 * r * Math.sin(tilt * Math.PI / 180) + 24) + 'px';
cards.forEach(function (c, i) { c.style.transform = 'rotateY(' + i * step + 'deg) translateZ(' + r + 'px)'; });
render();
}
// Turn the ring and shade each card by how far round it is.
function render() {
ring.style.transform = 'translateZ(' + -r + 'px) rotateX(' + -tilt + 'deg) rotateY(' + -state.a + 'deg)';
cards.forEach(function (c, i) {
var off = ((i * step - state.a) % 360 + 540) % 360 - 180; // -180..180, 0 = front
var cos = Math.cos(off * Math.PI / 180);
c.style.filter = 'brightness(' + (0.5 + 0.5 * Math.max(0, cos)).toFixed(3) + ')';
c.style.opacity = (0.55 + 0.45 * (cos + 1) / 2).toFixed(3);
});
}
function mark() {
cards.forEach(function (c, i) {
var on = i === current;
c.classList.toggle('crg-front', on);
if (!inThumb) c.setAttribute('aria-hidden', on ? 'false' : 'true');
});
}
function go(i, quiet) {
var target = i * step;
// Take the short way round.
target = state.a + (((target - state.a) % 360 + 540) % 360 - 180);
current = ((i % n) + n) % n;
mark();
if (tween) tween.pause();
if (reduced()) { state.a = target; render(); }
else tween = anime.animate(state, { a: target, duration: DEFAULTS.duration, ease: 'inOut(3)', onUpdate: render });
if (live && !quiet) {
var cap = cards[current].querySelector('figcaption,h2,h3,[alt],[aria-label]');
live.textContent = 'Item ' + (current + 1) + ' of ' + n + (cap ? ': ' + (cap.getAttribute('alt') || cap.getAttribute('aria-label') || cap.textContent.trim()) : '');
}
}
el._crg = function (i) { touched(); go(i); };
// A visitor took over: hold the autoplay for a while.
function touched() { idleUntil = Date.now() + 6000; }
el.addEventListener('keydown', function (e) {
if (e.target !== el) return;
var to = e.key === 'ArrowLeft' ? current - 1 : e.key === 'ArrowRight' ? current + 1 : null;
if (to === null) return;
e.preventDefault();
touched();
go(to);
});
// Drag or swipe spins the ring; a click on a side card brings it round.
if (!inThumb) {
var down = null, dragged = false;
stage.addEventListener('pointerdown', function (e) {
if (e.button > 0) return;
down = { x: e.clientX, a: state.a, id: e.pointerId };
dragged = false;
});
stage.addEventListener('pointermove', function (e) {
if (!down) return;
var dx = e.clientX - down.x;
if (!dragged && Math.abs(dx) < 6) return;
if (!dragged) { dragged = true; touched(); if (tween) tween.pause(); stage.setPointerCapture(down.id); }
state.a = down.a - dx * 180 / (Math.PI * r) * 1.2;
render();
});
var release = function () {
if (!down) return;
if (dragged) { touched(); go(Math.round(state.a / step)); }
down = null;
};
stage.addEventListener('pointerup', release);
stage.addEventListener('pointercancel', release);
stage.addEventListener('click', function (e) {
if (dragged) { dragged = false; e.preventDefault(); return; }
var card = cards.find(function (c) { return c.contains(e.target); });
if (!card || card.classList.contains('crg-front')) return;
e.preventDefault();
touched();
go(Math.round(state.a / step) + ((((cards.indexOf(card) - current) % n) + n + Math.floor(n / 2)) % n - Math.floor(n / 2)));
}, true);
el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') paused = true; });
el.addEventListener('pointerleave', function () { paused = false; });
el.addEventListener('focusin', function () { paused = true; });
el.addEventListener('focusout', function () { paused = false; });
}
mark();
measure();
if (window.ResizeObserver) new ResizeObserver(measure).observe(cards[0]);
// Turn on a timer while on screen.
if (interval > 0 && !reduced()) {
new IntersectionObserver(function (entries) {
clearInterval(timer);
timer = null;
if (!entries[0].isIntersecting) return;
timer = setInterval(function () {
if (paused || down || Date.now() < idleUntil || el.closest('[hidden]')) return;
go(Math.round(state.a / step) + 1, true);
}, interval);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-carousel-ring]').forEach(function (el) {
if (el._crg) return;
el._crg = true;
setup(el);
});
}
window.animacious = window.animacious || {};
window.animacious.carouselRing = function (el, i) { if (el && typeof el._crg === 'function') el._crg(i); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-carousel-ring aria-label="Gallery">
<figure><img src="one.jpg" alt="…"></figure>
<figure><img src="two.jpg" alt="…"></figure>
<figure><img src="three.jpg" alt="…"></figure>
<figure><img src="four.jpg" alt="…"></figure>
<figure><img src="five.jpg" alt="…"></figure>
</div>