/* 3D carousel rings. Without JavaScript, or with prefers-reduced-motion, both are flat: the ring is a scrolling row of cards and
the drum is a row of radio pills. The script adds .is-3d, sets the radius (--r), the step angle (--step) and each item's
index (--k), and turns the ring to --i. Cards that face away are inert, so focus and screen readers stay on the front one. */
.rg,.dr{--ink:#0f172a;--muted:#475569;--focus:#1d4ed8;color:var(--ink)}
.rg :where(ul,ol,h3,p,legend){margin:0;padding:0;list-style:none}
.rg .sr-only,.dr .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
/* A. ring of cards */
.rg-ring{display:flex;gap:16px;overflow-x:auto;scroll-snap-type:x mandatory;padding:4px 4px 16px}
.rg-card{position:relative;flex:0 0 200px;scroll-snap-align:start;display:flex;flex-direction:column;border-radius:18px;overflow:hidden;background:#fff;box-shadow:0 0 0 1px rgba(15,23,42,.08),0 22px 34px -24px rgba(15,23,42,.55)}
.rg-art{height:124px;background:var(--art)}
.rg-body{display:flex;flex-direction:column;gap:2px;flex:1;padding:14px 16px 16px}
.rg-card h3{font-size:1.05rem;font-weight:800}
.rg-card p{font-size:.84rem;color:var(--muted)}
.rg-card .rg-price{margin-top:6px;color:var(--ink);font-weight:700}
.rg-card a{margin-top:10px;align-self:flex-start;font-size:.85rem;font-weight:800;color:var(--focus);text-decoration:none}
.rg-card a:hover{text-decoration:underline}
.rg a:focus-visible,.rg button:focus-visible{outline:3px solid var(--focus);outline-offset:3px}
.rg-controls{display:none;align-items:center;justify-content:center;gap:16px;margin-top:6px}
.rg-btn,.dr-btn{display:grid;place-items:center;width:44px;height:44px;border:1px solid #94a3b8;border-radius:50%;background:#fff;color:var(--ink);cursor:pointer;transition:background-color .2s,border-color .2s}
.rg-btn:hover,.dr-btn:hover{border-color:var(--ink);background:#f8fafc}
.rg-btn svg,.dr-btn svg{width:20px;height:20px}
.rg-status{min-width:190px;text-align:center;font-size:.9rem;font-weight:700}
.rg.is-3d .rg-stage{height:350px;perspective:1100px;overflow:hidden;display:grid;place-items:center}
.rg.is-3d .rg-ring{display:block;position:relative;width:200px;height:272px;padding:0;overflow:visible;transform-style:preserve-3d;
transform:translateZ(calc(var(--r) * -1)) rotateY(calc(var(--i, 0) * var(--step) * -1deg));transition:transform .8s cubic-bezier(.3,.8,.25,1)}
.rg.is-3d .rg-card{position:absolute;inset:0;transform:rotateY(calc(var(--k) * var(--step) * 1deg)) translateZ(var(--r));-webkit-backface-visibility:hidden;backface-visibility:hidden}
.rg.is-3d .rg-controls{display:flex}
/* B. drum picker: radios on a cylinder turned on the X axis */
.dr{display:grid;width:fit-content;max-width:100%;grid-template-columns:auto auto;align-items:center;justify-content:start;gap:18px 28px;min-width:0;margin:0;padding:24px 26px;border:0;border-radius:22px;background:#fff;box-shadow:0 22px 34px -26px rgba(15,23,42,.5)}
.dr legend{float:left;grid-column:1/-1;font-size:1.05rem;font-weight:800}
.dr-view{grid-column:1/-1}
.dr-drum{display:flex;flex-wrap:wrap;gap:8px;margin:0;padding:0;list-style:none}
.dr-drum label{display:block;cursor:pointer}
.dr-drum input{position:absolute;opacity:0;width:1px;height:1px}
.dr-drum span{display:block;padding:8px 14px;border:1px solid #94a3b8;border-radius:999px;font-size:.95rem;font-weight:700;font-variant-numeric:tabular-nums;color:var(--muted)}
.dr-drum input:checked + span{background:var(--ink);border-color:var(--ink);color:#fff}
.dr-drum input:focus-visible + span{outline:3px solid var(--focus);outline-offset:3px}
.dr-side{display:none;flex-direction:column;gap:10px}
.dr-btn:focus-visible{outline:3px solid var(--focus);outline-offset:3px}
.dr-out{grid-column:1/-1;font-size:.92rem;color:var(--muted)}
.dr-out b{color:var(--ink)}
.dr.is-3d .dr-view{grid-column:auto}
.dr.is-3d .dr-drum{display:block;position:relative;width:150px;height:46px;margin:62px 0;transform-style:preserve-3d;
transform:translateZ(calc(var(--r) * -1)) rotateX(calc(var(--i, 0) * var(--step) * 1deg));transition:transform .6s cubic-bezier(.3,.8,.25,1)}
.dr.is-3d .dr-view{perspective:600px;-webkit-mask-image:linear-gradient(transparent,#000 30%,#000 70%,transparent);mask-image:linear-gradient(transparent,#000 30%,#000 70%,transparent)}
.dr.is-3d .dr-drum li{position:absolute;inset:0;transform:rotateX(calc(var(--k) * var(--step) * -1deg)) translateZ(var(--r));-webkit-backface-visibility:hidden;backface-visibility:hidden}
.dr.is-3d .dr-drum label{height:100%}
.dr.is-3d .dr-drum span{height:100%;display:grid;place-items:center;padding:0;border:0;border-radius:12px;background:#f1f5f9;font-size:1.25rem;color:var(--ink)}
.dr.is-3d .dr-drum input:checked + span{background:var(--ink);color:#fff}
.dr.is-3d .dr-side{display:flex}
@media (prefers-reduced-motion:reduce){
.rg-ring,.dr-drum,.rg-btn,.dr-btn{transition:none}
}
JavaScript
/* Ring and drum: .is-3d turns on the 3D layout unless the visitor prefers reduced motion, in which case both stay flat.
--i is a running count, so the ring always takes the short way round. */
(function () {
var mq = window.matchMedia('(prefers-reduced-motion: reduce)');
var wrap = function (k, n) { return ((k % n) + n) % n; };
/* lay out without animating, for the first render and when the motion preference changes */
var still = function (el, fn) { el.style.transition = 'none'; fn(); void el.offsetWidth; el.style.transition = ''; };
document.querySelectorAll('[data-ring]').forEach(function (ring) {
var track = ring.querySelector('.rg-ring'), cards = Array.prototype.slice.call(track.children);
var status = ring.querySelector('.rg-status'), n = cards.length, i = 0;
cards.forEach(function (c, k) { c.style.setProperty('--k', k); });
ring.style.setProperty('--step', 360 / n);
function render(announce) {
var on = !mq.matches, cur = wrap(i, n);
ring.classList.toggle('is-3d', on);
ring.style.setProperty('--r', Math.round(cards[0].offsetWidth / 2 / Math.tan(Math.PI / n)) + 28 + 'px');
track.style.setProperty('--i', i);
cards.forEach(function (c, k) { c.inert = on && k !== cur; });
var name = cards[cur].querySelector('h3').textContent;
status.textContent = announce || !status.textContent ? name + ', ' + (cur + 1) + ' of ' + n : status.textContent;
}
ring.querySelectorAll('[data-dir]').forEach(function (b) {
b.addEventListener('click', function () { i += +b.dataset.dir; render(true); });
});
mq.addEventListener('change', function () { still(track, function () { render(false); }); });
still(track, function () { render(false); });
});
document.querySelectorAll('[data-drum]').forEach(function (dr) {
var drum = dr.querySelector('.dr-drum'), radios = Array.prototype.slice.call(dr.querySelectorAll('input[type=radio]'));
var out = dr.querySelector('.dr-out b'), n = radios.length, pos = 0;
radios.forEach(function (r, k) { r.closest('li').style.setProperty('--k', k); });
dr.style.setProperty('--step', 360 / n);
function current() { for (var k = 0; k < n; k++) if (radios[k].checked) return k; return 0; }
function render() {
var on = !mq.matches, d = wrap(current() - wrap(pos, n), n);
if (d > n / 2) d -= n;
pos += d;
dr.classList.toggle('is-3d', on);
dr.style.setProperty('--r', Math.round(drum.offsetHeight / 2 / Math.tan(Math.PI / n)) + 'px');
drum.style.setProperty('--i', pos);
out.textContent = radios[current()].value;
}
radios.forEach(function (r) { r.addEventListener('change', render); });
dr.querySelectorAll('[data-dir]').forEach(function (b) {
b.addEventListener('click', function () { radios[wrap(current() + +b.dataset.dir, n)].checked = true; render(); });
});
mq.addEventListener('change', function () { still(drum, render); });
still(drum, render);
});
})();
/* 3D carousel rings on Bootstrap .card, .btn and .btn-check. Without JavaScript, or with prefers-reduced-motion, both are flat: the ring is a scrolling row of cards and
the drum is a row of radio pills. The script adds .is-3d, sets the radius (--r), the step angle (--step) and each item's
index (--k), and turns the ring to --i. Cards that face away are inert, so focus and screen readers stay on the front one. */
.rg,.dr{--ink:#0f172a;--muted:#475569;--focus:#1d4ed8;color:var(--ink)}
.rg :where(ul,ol,h3,p,legend){margin:0;padding:0;list-style:none}
/* A. ring of cards */
.rg-ring{display:flex;gap:16px;overflow-x:auto;scroll-snap-type:x mandatory;padding:4px 4px 16px}
.rg-card{--bs-card-border-width:0;--bs-card-border-radius:18px;--bs-card-inner-border-radius:18px;flex:0 0 200px;scroll-snap-align:start;overflow:hidden;box-shadow:0 0 0 1px rgba(15,23,42,.08),0 22px 34px -24px rgba(15,23,42,.55)}
.rg-art{height:124px;background:var(--art)}
.rg-card .card-body{display:flex;flex-direction:column;gap:2px;padding:14px 16px 16px}
.rg-card h3{font-size:1.05rem;font-weight:800}
.rg-card p{font-size:.84rem;color:var(--muted)}
.rg-card .rg-price{margin-top:6px;color:var(--ink);font-weight:700}
.rg-card a{margin-top:10px;align-self:flex-start;font-size:.85rem;font-weight:800;color:var(--focus);text-decoration:none}
.rg-card a:hover{text-decoration:underline}
.rg a:focus-visible,.rg .btn:focus-visible{outline:3px solid var(--focus);outline-offset:3px;box-shadow:none}
.rg-controls{display:none;align-items:center;justify-content:center;gap:16px;margin-top:6px}
.rg-btn,.dr-btn{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-bg:#fff;--bs-btn-color:var(--ink);--bs-btn-border-color:#94a3b8;--bs-btn-hover-bg:#f8fafc;--bs-btn-hover-color:var(--ink);--bs-btn-hover-border-color:var(--ink);
--bs-btn-active-bg:#e2e8f0;--bs-btn-active-color:var(--ink);--bs-btn-active-border-color:var(--ink);display:grid;place-items:center;width:44px;height:44px}
.rg-btn svg,.dr-btn svg{width:20px;height:20px}
.rg-status{min-width:190px;text-align:center;font-size:.9rem;font-weight:700}
.rg.is-3d .rg-stage{height:350px;perspective:1100px;overflow:hidden;display:grid;place-items:center}
.rg.is-3d .rg-ring{display:block;position:relative;width:200px;height:272px;padding:0;overflow:visible;transform-style:preserve-3d;
transform:translateZ(calc(var(--r) * -1)) rotateY(calc(var(--i, 0) * var(--step) * -1deg));transition:transform .8s cubic-bezier(.3,.8,.25,1)}
.rg.is-3d .rg-card{position:absolute;inset:0;transform:rotateY(calc(var(--k) * var(--step) * 1deg)) translateZ(var(--r));-webkit-backface-visibility:hidden;backface-visibility:hidden}
.rg.is-3d .rg-controls{display:flex}
/* B. drum picker: radios on a cylinder turned on the X axis */
.dr{display:grid;width:fit-content;max-width:100%;grid-template-columns:auto auto;align-items:center;justify-content:start;gap:18px 28px;min-width:0;margin:0;padding:24px 26px;border:0;border-radius:22px;background:#fff;box-shadow:0 22px 34px -26px rgba(15,23,42,.5)}
.dr legend{float:left;grid-column:1/-1;margin:0;font-size:1.05rem;font-weight:800;line-height:normal}
.dr-view{grid-column:1/-1}
.dr-drum{display:flex;flex-wrap:wrap;gap:8px;margin:0;padding:0;list-style:none}
.dr-drum li{position:relative}
.dr-drum .btn{--bs-btn-line-height:normal;--bs-btn-padding-x:14px;--bs-btn-padding-y:8px;--bs-btn-font-size:.95rem;--bs-btn-font-weight:700;--bs-btn-border-radius:999px;--bs-btn-color:var(--muted);--bs-btn-border-color:#94a3b8;
--bs-btn-hover-color:var(--ink);--bs-btn-hover-border-color:var(--ink);--bs-btn-active-bg:var(--ink);--bs-btn-active-border-color:var(--ink);--bs-btn-active-color:#fff;display:block;font-variant-numeric:tabular-nums}
.dr-drum .btn-check:focus-visible + .btn{outline:3px solid var(--focus);outline-offset:3px;box-shadow:none}
.dr-side{display:none;flex-direction:column;gap:10px}
.dr .dr-btn:focus-visible{outline:3px solid var(--focus);outline-offset:3px;box-shadow:none}
.dr-out{grid-column:1/-1;font-size:.92rem;color:var(--muted)}
.dr-out b{color:var(--ink)}
.dr.is-3d .dr-view{grid-column:auto}
.dr.is-3d .dr-drum{display:block;position:relative;width:150px;height:46px;margin:62px 0;transform-style:preserve-3d;
transform:translateZ(calc(var(--r) * -1)) rotateX(calc(var(--i, 0) * var(--step) * 1deg));transition:transform .6s cubic-bezier(.3,.8,.25,1)}
.dr.is-3d .dr-view{perspective:600px;-webkit-mask-image:linear-gradient(transparent,#000 30%,#000 70%,transparent);mask-image:linear-gradient(transparent,#000 30%,#000 70%,transparent)}
.dr.is-3d .dr-drum li{position:absolute;inset:0;transform:rotateX(calc(var(--k) * var(--step) * -1deg)) translateZ(var(--r));-webkit-backface-visibility:hidden;backface-visibility:hidden}
.dr.is-3d .dr-drum .btn{height:100%;display:grid;place-items:center;padding:0;border:0;border-radius:12px;background:#f1f5f9;font-size:1.25rem;color:var(--ink)}
.dr.is-3d .dr-drum .btn-check:checked + .btn{background:var(--ink);color:#fff}
.dr.is-3d .dr-side{display:flex}
@media (prefers-reduced-motion:reduce){
.rg-ring,.dr-drum,.rg-btn,.dr-btn{transition:none}
}
JavaScript
/* Ring and drum: .is-3d turns on the 3D layout unless the visitor prefers reduced motion, in which case both stay flat.
--i is a running count, so the ring always takes the short way round. */
(function () {
var mq = window.matchMedia('(prefers-reduced-motion: reduce)');
var wrap = function (k, n) { return ((k % n) + n) % n; };
/* lay out without animating, for the first render and when the motion preference changes */
var still = function (el, fn) { el.style.transition = 'none'; fn(); void el.offsetWidth; el.style.transition = ''; };
document.querySelectorAll('[data-ring]').forEach(function (ring) {
var track = ring.querySelector('.rg-ring'), cards = Array.prototype.slice.call(track.children);
var status = ring.querySelector('.rg-status'), n = cards.length, i = 0;
cards.forEach(function (c, k) { c.style.setProperty('--k', k); });
ring.style.setProperty('--step', 360 / n);
function render(announce) {
var on = !mq.matches, cur = wrap(i, n);
ring.classList.toggle('is-3d', on);
ring.style.setProperty('--r', Math.round(cards[0].offsetWidth / 2 / Math.tan(Math.PI / n)) + 28 + 'px');
track.style.setProperty('--i', i);
cards.forEach(function (c, k) { c.inert = on && k !== cur; });
var name = cards[cur].querySelector('h3').textContent;
status.textContent = announce || !status.textContent ? name + ', ' + (cur + 1) + ' of ' + n : status.textContent;
}
ring.querySelectorAll('[data-dir]').forEach(function (b) {
b.addEventListener('click', function () { i += +b.dataset.dir; render(true); });
});
mq.addEventListener('change', function () { still(track, function () { render(false); }); });
still(track, function () { render(false); });
});
document.querySelectorAll('[data-drum]').forEach(function (dr) {
var drum = dr.querySelector('.dr-drum'), radios = Array.prototype.slice.call(dr.querySelectorAll('input[type=radio]'));
var out = dr.querySelector('.dr-out b'), n = radios.length, pos = 0;
radios.forEach(function (r, k) { r.closest('li').style.setProperty('--k', k); });
dr.style.setProperty('--step', 360 / n);
function current() { for (var k = 0; k < n; k++) if (radios[k].checked) return k; return 0; }
function render() {
var on = !mq.matches, d = wrap(current() - wrap(pos, n), n);
if (d > n / 2) d -= n;
pos += d;
dr.classList.toggle('is-3d', on);
dr.style.setProperty('--r', Math.round(drum.offsetHeight / 2 / Math.tan(Math.PI / n)) + 'px');
drum.style.setProperty('--i', pos);
out.textContent = radios[current()].value;
}
radios.forEach(function (r) { r.addEventListener('change', render); });
dr.querySelectorAll('[data-dir]').forEach(function (b) {
b.addEventListener('click', function () { radios[wrap(current() + +b.dataset.dir, n)].checked = true; render(); });
});
mq.addEventListener('change', function () { still(drum, render); });
still(drum, render);
});
})();
/* Ring and drum: .is-3d turns on the 3D layout unless the visitor prefers reduced motion, in which case both stay flat.
--i is a running count, so the ring always takes the short way round. */
(function () {
var mq = window.matchMedia('(prefers-reduced-motion: reduce)');
var wrap = function (k, n) { return ((k % n) + n) % n; };
/* lay out without animating, for the first render and when the motion preference changes */
var still = function (el, fn) { el.style.transition = 'none'; fn(); void el.offsetWidth; el.style.transition = ''; };
document.querySelectorAll('[data-ring]').forEach(function (ring) {
var track = ring.querySelector('.rg-ring'), cards = Array.prototype.slice.call(track.children);
var status = ring.querySelector('.rg-status'), n = cards.length, i = 0;
cards.forEach(function (c, k) { c.style.setProperty('--k', k); });
ring.style.setProperty('--step', 360 / n);
function render(announce) {
var on = !mq.matches, cur = wrap(i, n);
ring.classList.toggle('is-3d', on);
ring.style.setProperty('--r', Math.round(cards[0].offsetWidth / 2 / Math.tan(Math.PI / n)) + 28 + 'px');
track.style.setProperty('--i', i);
cards.forEach(function (c, k) { c.inert = on && k !== cur; });
var name = cards[cur].querySelector('h3').textContent;
status.textContent = announce || !status.textContent ? name + ', ' + (cur + 1) + ' of ' + n : status.textContent;
}
ring.querySelectorAll('[data-dir]').forEach(function (b) {
b.addEventListener('click', function () { i += +b.dataset.dir; render(true); });
});
mq.addEventListener('change', function () { still(track, function () { render(false); }); });
still(track, function () { render(false); });
});
document.querySelectorAll('[data-drum]').forEach(function (dr) {
var drum = dr.querySelector('.dr-drum'), radios = Array.prototype.slice.call(dr.querySelectorAll('input[type=radio]'));
var out = dr.querySelector('.dr-out b'), n = radios.length, pos = 0;
radios.forEach(function (r, k) { r.closest('li').style.setProperty('--k', k); });
dr.style.setProperty('--step', 360 / n);
function current() { for (var k = 0; k < n; k++) if (radios[k].checked) return k; return 0; }
function render() {
var on = !mq.matches, d = wrap(current() - wrap(pos, n), n);
if (d > n / 2) d -= n;
pos += d;
dr.classList.toggle('is-3d', on);
dr.style.setProperty('--r', Math.round(drum.offsetHeight / 2 / Math.tan(Math.PI / n)) + 'px');
drum.style.setProperty('--i', pos);
out.textContent = radios[current()].value;
}
radios.forEach(function (r) { r.addEventListener('change', render); });
dr.querySelectorAll('[data-dir]').forEach(function (b) {
b.addEventListener('click', function () { radios[wrap(current() + +b.dataset.dir, n)].checked = true; render(); });
});
mq.addEventListener('change', function () { still(drum, render); });
still(drum, render);
});
})();
About this set
Carousels that turn in 3D, in two variants. The card ring places six destination cards, Lofoten, Atacama, Kyoto, the Azores, the Dolomites and Wadi Rum, around a vertical axis; previous and next buttons turn the ring one card at a time and a status line names the card in front. The drum picker stands eight pick-up times on a cylinder that turns on the X axis, with earlier and later buttons beside it and a sentence that confirms the chosen time. A short vanilla script measures the item size, sets the radius and step angle as custom properties and turns the ring to the current index, always taking the short way round. Without JavaScript, or when the visitor prefers reduced motion, both stay flat: the ring is a row of cards that scrolls sideways and the drum is a row of radio pills, with the 3D buttons hidden. The drum is built on radio inputs, so the chosen time submits with a form and the arrow keys change it in every mode. Use the ring for a short list of featured items and the drum for picking one value from a short list.
What’s included
Six-card ring turned with rotateY by previous and next buttons
Drum picker of radio inputs turned with rotateX
Radius and step angle computed from the item size
Flat scrolling list and radio pills without JavaScript or with reduced motion
Cards that face away are inert, so focus stays on the front card
Accessibility
The ring is a section with aria-roledescription="carousel" and a label; its buttons are named Previous destination and Next destination, and a polite live region announces the card in front, such as Atacama, 2 of 6. Cards that face away are inert, so Tab and screen readers stay on the visible card. The drum is a fieldset of radios with a legend, so the arrow keys change the time, and the confirmation sentence is a live output. All controls show a 3px focus outline, and under prefers-reduced-motion both components are flat lists that never turn.
Customise it
The card width sets the ring radius, so change the 200px width on .rg-card and the height of .rg-stage together; the drum item height is the 46px on .dr-drum. Card art is the --art gradient on each card. In Tailwind the same values are utilities behind the group-[.is-3d]: variant.