Open demo

CSS code

HTML
<div><span class="lbl">Card ring with previous and next buttons</span>
<section class="rg" data-ring aria-roledescription="carousel" aria-label="Weekend escapes">
  <div class="rg-stage">
    <ul class="rg-ring">
      <li class="rg-card" style="--art:linear-gradient(162deg,transparent 56%,#134e4a 56.5%),linear-gradient(205deg,transparent 48%,#0f766e 48.5%),linear-gradient(#7dd3fc,#e0f2fe)"><div class="rg-art" aria-hidden="true"></div>
        <div class="rg-body"><h3>Lofoten</h3><p>Norway · fjord cabins</p><p class="rg-price">From €140 a night</p><a href="#">See stays<span class="sr-only"> in Lofoten</span></a></div></li>
      <li class="rg-card" style="--art:radial-gradient(120% 60% at 30% 100%,#c2410c 50%,transparent 50.5%),radial-gradient(90% 50% at 85% 100%,#ea580c 50%,transparent 50.5%),linear-gradient(#4c1d95,#f472b6)"><div class="rg-art" aria-hidden="true"></div>
        <div class="rg-body"><h3>Atacama</h3><p>Chile · desert lodges</p><p class="rg-price">From €95 a night</p><a href="#">See stays<span class="sr-only"> in Atacama</span></a></div></li>
      <li class="rg-card" style="--art:radial-gradient(circle at 70% 38%,#fff1f2 0 14px,transparent 15px),radial-gradient(140% 70% at 20% 100%,#4c0519 50%,transparent 50.5%),linear-gradient(#fb7185,#fecdd3)"><div class="rg-art" aria-hidden="true"></div>
        <div class="rg-body"><h3>Kyoto</h3><p>Japan · ryokan stays</p><p class="rg-price">From €180 a night</p><a href="#">See stays<span class="sr-only"> in Kyoto</span></a></div></li>
      <li class="rg-card" style="--art:radial-gradient(110% 60% at 75% 100%,#15803d 50%,transparent 50.5%),linear-gradient(transparent 70%,#0369a1 70%),linear-gradient(#bae6fd,#f0f9ff)"><div class="rg-art" aria-hidden="true"></div>
        <div class="rg-body"><h3>Azores</h3><p>Portugal · crater lakes</p><p class="rg-price">From €110 a night</p><a href="#">See stays<span class="sr-only"> in the Azores</span></a></div></li>
      <li class="rg-card" style="--art:linear-gradient(150deg,transparent 52%,#475569 52.5%),linear-gradient(215deg,transparent 46%,#334155 46.5%),linear-gradient(#fbbf24,#fef3c7)"><div class="rg-art" aria-hidden="true"></div>
        <div class="rg-body"><h3>Dolomites</h3><p>Italy · mountain huts</p><p class="rg-price">From €85 a night</p><a href="#">See stays<span class="sr-only"> in the Dolomites</span></a></div></li>
      <li class="rg-card" style="--art:linear-gradient(170deg,transparent 60%,#9a3412 60.5%),radial-gradient(70% 90% at 25% 100%,#b91c1c 50%,transparent 50.5%),linear-gradient(#fdba74,#ffedd5)"><div class="rg-art" aria-hidden="true"></div>
        <div class="rg-body"><h3>Wadi Rum</h3><p>Jordan · desert camps</p><p class="rg-price">From €70 a night</p><a href="#">See stays<span class="sr-only"> in Wadi Rum</span></a></div></li>
    </ul>
  </div>
  <div class="rg-controls">
    <button class="rg-btn" type="button" data-dir="-1" aria-label="Previous destination"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 5l-7 7 7 7"/></svg></button>
    <p class="rg-status" aria-live="polite"></p>
    <button class="rg-btn" type="button" data-dir="1" aria-label="Next destination"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 5l7 7-7 7"/></svg></button>
  </div>
</section></div>

<div><span class="lbl">Drum picker</span>
<fieldset class="dr" data-drum>
  <legend>Pick-up time at Harbour Street</legend>
  <div class="dr-view">
    <ol class="dr-drum">
      <li><label><input type="radio" name="slot" value="09:00"><span>09:00</span></label></li>
      <li><label><input type="radio" name="slot" value="09:30"><span>09:30</span></label></li>
      <li><label><input type="radio" name="slot" value="10:00" checked><span>10:00</span></label></li>
      <li><label><input type="radio" name="slot" value="10:30"><span>10:30</span></label></li>
      <li><label><input type="radio" name="slot" value="11:00"><span>11:00</span></label></li>
      <li><label><input type="radio" name="slot" value="11:30"><span>11:30</span></label></li>
      <li><label><input type="radio" name="slot" value="12:00"><span>12:00</span></label></li>
      <li><label><input type="radio" name="slot" value="12:30"><span>12:30</span></label></li>
    </ol>
  </div>
  <div class="dr-side">
    <button class="dr-btn" type="button" data-dir="-1" aria-label="Earlier time"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 15l7-7 7 7"/></svg></button>
    <button class="dr-btn" type="button" data-dir="1" aria-label="Later time"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 9l7 7 7-7"/></svg></button>
  </div>
  <output class="dr-out" aria-live="polite">Your order will be ready at <b>10:00</b>.</output>
</fieldset></div>
CSS
/* 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);
  });
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Card ring with previous and next buttons</span>
<section class="rg" data-ring aria-roledescription="carousel" aria-label="Weekend escapes">
  <div class="rg-stage">
    <ul class="rg-ring list-unstyled mb-0">
      <li class="card rg-card" style="--art:linear-gradient(162deg,transparent 56%,#134e4a 56.5%),linear-gradient(205deg,transparent 48%,#0f766e 48.5%),linear-gradient(#7dd3fc,#e0f2fe)"><div class="rg-art" aria-hidden="true"></div>
        <div class="card-body"><h3 class="mb-0">Lofoten</h3><p class="mb-0">Norway · fjord cabins</p><p class="rg-price mb-0">From €140 a night</p><a class="card-link" href="#">See stays<span class="visually-hidden"> in Lofoten</span></a></div></li>
      <li class="card rg-card" style="--art:radial-gradient(120% 60% at 30% 100%,#c2410c 50%,transparent 50.5%),radial-gradient(90% 50% at 85% 100%,#ea580c 50%,transparent 50.5%),linear-gradient(#4c1d95,#f472b6)"><div class="rg-art" aria-hidden="true"></div>
        <div class="card-body"><h3 class="mb-0">Atacama</h3><p class="mb-0">Chile · desert lodges</p><p class="rg-price mb-0">From €95 a night</p><a class="card-link" href="#">See stays<span class="visually-hidden"> in Atacama</span></a></div></li>
      <li class="card rg-card" style="--art:radial-gradient(circle at 70% 38%,#fff1f2 0 14px,transparent 15px),radial-gradient(140% 70% at 20% 100%,#4c0519 50%,transparent 50.5%),linear-gradient(#fb7185,#fecdd3)"><div class="rg-art" aria-hidden="true"></div>
        <div class="card-body"><h3 class="mb-0">Kyoto</h3><p class="mb-0">Japan · ryokan stays</p><p class="rg-price mb-0">From €180 a night</p><a class="card-link" href="#">See stays<span class="visually-hidden"> in Kyoto</span></a></div></li>
      <li class="card rg-card" style="--art:radial-gradient(110% 60% at 75% 100%,#15803d 50%,transparent 50.5%),linear-gradient(transparent 70%,#0369a1 70%),linear-gradient(#bae6fd,#f0f9ff)"><div class="rg-art" aria-hidden="true"></div>
        <div class="card-body"><h3 class="mb-0">Azores</h3><p class="mb-0">Portugal · crater lakes</p><p class="rg-price mb-0">From €110 a night</p><a class="card-link" href="#">See stays<span class="visually-hidden"> in the Azores</span></a></div></li>
      <li class="card rg-card" style="--art:linear-gradient(150deg,transparent 52%,#475569 52.5%),linear-gradient(215deg,transparent 46%,#334155 46.5%),linear-gradient(#fbbf24,#fef3c7)"><div class="rg-art" aria-hidden="true"></div>
        <div class="card-body"><h3 class="mb-0">Dolomites</h3><p class="mb-0">Italy · mountain huts</p><p class="rg-price mb-0">From €85 a night</p><a class="card-link" href="#">See stays<span class="visually-hidden"> in the Dolomites</span></a></div></li>
      <li class="card rg-card" style="--art:linear-gradient(170deg,transparent 60%,#9a3412 60.5%),radial-gradient(70% 90% at 25% 100%,#b91c1c 50%,transparent 50.5%),linear-gradient(#fdba74,#ffedd5)"><div class="rg-art" aria-hidden="true"></div>
        <div class="card-body"><h3 class="mb-0">Wadi Rum</h3><p class="mb-0">Jordan · desert camps</p><p class="rg-price mb-0">From €70 a night</p><a class="card-link" href="#">See stays<span class="visually-hidden"> in Wadi Rum</span></a></div></li>
    </ul>
  </div>
  <div class="rg-controls">
    <button class="btn rounded-circle rg-btn" type="button" data-dir="-1" aria-label="Previous destination"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 5l-7 7 7 7"/></svg></button>
    <p class="rg-status mb-0" aria-live="polite"></p>
    <button class="btn rounded-circle rg-btn" type="button" data-dir="1" aria-label="Next destination"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 5l7 7-7 7"/></svg></button>
  </div>
</section></div>

<div><span class="lbl">Drum picker</span>
<fieldset class="dr" data-drum>
  <legend>Pick-up time at Harbour Street</legend>
  <div class="dr-view">
    <ol class="dr-drum list-unstyled">
      <li><input class="btn-check" type="radio" name="slot" id="slot-0900" value="09:00" autocomplete="off"><label class="btn" for="slot-0900">09:00</label></li>
      <li><input class="btn-check" type="radio" name="slot" id="slot-0930" value="09:30" autocomplete="off"><label class="btn" for="slot-0930">09:30</label></li>
      <li><input class="btn-check" type="radio" name="slot" id="slot-1000" value="10:00" autocomplete="off" checked><label class="btn" for="slot-1000">10:00</label></li>
      <li><input class="btn-check" type="radio" name="slot" id="slot-1030" value="10:30" autocomplete="off"><label class="btn" for="slot-1030">10:30</label></li>
      <li><input class="btn-check" type="radio" name="slot" id="slot-1100" value="11:00" autocomplete="off"><label class="btn" for="slot-1100">11:00</label></li>
      <li><input class="btn-check" type="radio" name="slot" id="slot-1130" value="11:30" autocomplete="off"><label class="btn" for="slot-1130">11:30</label></li>
      <li><input class="btn-check" type="radio" name="slot" id="slot-1200" value="12:00" autocomplete="off"><label class="btn" for="slot-1200">12:00</label></li>
      <li><input class="btn-check" type="radio" name="slot" id="slot-1230" value="12:30" autocomplete="off"><label class="btn" for="slot-1230">12:30</label></li>
    </ol>
  </div>
  <div class="dr-side">
    <button class="btn rounded-circle dr-btn" type="button" data-dir="-1" aria-label="Earlier time"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 15l7-7 7 7"/></svg></button>
    <button class="btn rounded-circle dr-btn" type="button" data-dir="1" aria-label="Later time"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 9l7 7 7-7"/></svg></button>
  </div>
  <output class="dr-out" aria-live="polite">Your order will be ready at <b>10:00</b>.</output>
</fieldset></div>
CSS
/* 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);
  });
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-[18px] block text-[.72rem] tracking-[.12em] text-[#475569] uppercase">Card ring with previous and next buttons</span>
<section class="group" data-ring aria-roledescription="carousel" aria-label="Weekend escapes">
  <div class="group-[.is-3d]:grid group-[.is-3d]:h-[350px] group-[.is-3d]:place-items-center group-[.is-3d]:overflow-hidden group-[.is-3d]:perspective-[1100px]">
    <ul class="rg-ring flex snap-x snap-mandatory gap-4 overflow-x-auto px-1 pt-1 pb-4 transition-transform duration-800 ease-[cubic-bezier(.3,.8,.25,1)] group-[.is-3d]:relative group-[.is-3d]:block group-[.is-3d]:h-[272px] group-[.is-3d]:w-[200px] group-[.is-3d]:overflow-visible group-[.is-3d]:p-0 group-[.is-3d]:transform-3d group-[.is-3d]:[transform:translateZ(calc(var(--r)*-1))_rotateY(calc(var(--i,0)*var(--step)*-1deg))] motion-reduce:transition-none">
      <li class="rg-card relative flex flex-[0_0_200px] snap-start flex-col overflow-hidden rounded-[18px] bg-white shadow-[0_0_0_1px_rgba(15,23,42,.08),0_22px_34px_-24px_rgba(15,23,42,.55)] group-[.is-3d]:absolute group-[.is-3d]:inset-0 group-[.is-3d]:backface-hidden group-[.is-3d]:[transform:rotateY(calc(var(--k)*var(--step)*1deg))_translateZ(var(--r))]" style="--art:linear-gradient(162deg,transparent 56%,#134e4a 56.5%),linear-gradient(205deg,transparent 48%,#0f766e 48.5%),linear-gradient(#7dd3fc,#e0f2fe)"><div class="h-[124px] [background:var(--art)]" aria-hidden="true"></div>
        <div class="flex flex-1 flex-col gap-0.5 px-4 pt-3.5 pb-4"><h3 class="text-[1.05rem] font-extrabold">Lofoten</h3><p class="text-[.84rem] text-[#475569]">Norway · fjord cabins</p><p class="mt-1.5 text-[.84rem] font-bold">From €140 a night</p><a class="mt-2.5 self-start text-[.85rem] font-extrabold text-[#1d4ed8] no-underline hover:underline focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#1d4ed8]" href="#">See stays<span class="sr-only"> in Lofoten</span></a></div></li>
      <li class="rg-card relative flex flex-[0_0_200px] snap-start flex-col overflow-hidden rounded-[18px] bg-white shadow-[0_0_0_1px_rgba(15,23,42,.08),0_22px_34px_-24px_rgba(15,23,42,.55)] group-[.is-3d]:absolute group-[.is-3d]:inset-0 group-[.is-3d]:backface-hidden group-[.is-3d]:[transform:rotateY(calc(var(--k)*var(--step)*1deg))_translateZ(var(--r))]" style="--art:radial-gradient(120% 60% at 30% 100%,#c2410c 50%,transparent 50.5%),radial-gradient(90% 50% at 85% 100%,#ea580c 50%,transparent 50.5%),linear-gradient(#4c1d95,#f472b6)"><div class="h-[124px] [background:var(--art)]" aria-hidden="true"></div>
        <div class="flex flex-1 flex-col gap-0.5 px-4 pt-3.5 pb-4"><h3 class="text-[1.05rem] font-extrabold">Atacama</h3><p class="text-[.84rem] text-[#475569]">Chile · desert lodges</p><p class="mt-1.5 text-[.84rem] font-bold">From €95 a night</p><a class="mt-2.5 self-start text-[.85rem] font-extrabold text-[#1d4ed8] no-underline hover:underline focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#1d4ed8]" href="#">See stays<span class="sr-only"> in Atacama</span></a></div></li>
      <li class="rg-card relative flex flex-[0_0_200px] snap-start flex-col overflow-hidden rounded-[18px] bg-white shadow-[0_0_0_1px_rgba(15,23,42,.08),0_22px_34px_-24px_rgba(15,23,42,.55)] group-[.is-3d]:absolute group-[.is-3d]:inset-0 group-[.is-3d]:backface-hidden group-[.is-3d]:[transform:rotateY(calc(var(--k)*var(--step)*1deg))_translateZ(var(--r))]" style="--art:radial-gradient(circle at 70% 38%,#fff1f2 0 14px,transparent 15px),radial-gradient(140% 70% at 20% 100%,#4c0519 50%,transparent 50.5%),linear-gradient(#fb7185,#fecdd3)"><div class="h-[124px] [background:var(--art)]" aria-hidden="true"></div>
        <div class="flex flex-1 flex-col gap-0.5 px-4 pt-3.5 pb-4"><h3 class="text-[1.05rem] font-extrabold">Kyoto</h3><p class="text-[.84rem] text-[#475569]">Japan · ryokan stays</p><p class="mt-1.5 text-[.84rem] font-bold">From €180 a night</p><a class="mt-2.5 self-start text-[.85rem] font-extrabold text-[#1d4ed8] no-underline hover:underline focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#1d4ed8]" href="#">See stays<span class="sr-only"> in Kyoto</span></a></div></li>
      <li class="rg-card relative flex flex-[0_0_200px] snap-start flex-col overflow-hidden rounded-[18px] bg-white shadow-[0_0_0_1px_rgba(15,23,42,.08),0_22px_34px_-24px_rgba(15,23,42,.55)] group-[.is-3d]:absolute group-[.is-3d]:inset-0 group-[.is-3d]:backface-hidden group-[.is-3d]:[transform:rotateY(calc(var(--k)*var(--step)*1deg))_translateZ(var(--r))]" style="--art:radial-gradient(110% 60% at 75% 100%,#15803d 50%,transparent 50.5%),linear-gradient(transparent 70%,#0369a1 70%),linear-gradient(#bae6fd,#f0f9ff)"><div class="h-[124px] [background:var(--art)]" aria-hidden="true"></div>
        <div class="flex flex-1 flex-col gap-0.5 px-4 pt-3.5 pb-4"><h3 class="text-[1.05rem] font-extrabold">Azores</h3><p class="text-[.84rem] text-[#475569]">Portugal · crater lakes</p><p class="mt-1.5 text-[.84rem] font-bold">From €110 a night</p><a class="mt-2.5 self-start text-[.85rem] font-extrabold text-[#1d4ed8] no-underline hover:underline focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#1d4ed8]" href="#">See stays<span class="sr-only"> in the Azores</span></a></div></li>
      <li class="rg-card relative flex flex-[0_0_200px] snap-start flex-col overflow-hidden rounded-[18px] bg-white shadow-[0_0_0_1px_rgba(15,23,42,.08),0_22px_34px_-24px_rgba(15,23,42,.55)] group-[.is-3d]:absolute group-[.is-3d]:inset-0 group-[.is-3d]:backface-hidden group-[.is-3d]:[transform:rotateY(calc(var(--k)*var(--step)*1deg))_translateZ(var(--r))]" style="--art:linear-gradient(150deg,transparent 52%,#475569 52.5%),linear-gradient(215deg,transparent 46%,#334155 46.5%),linear-gradient(#fbbf24,#fef3c7)"><div class="h-[124px] [background:var(--art)]" aria-hidden="true"></div>
        <div class="flex flex-1 flex-col gap-0.5 px-4 pt-3.5 pb-4"><h3 class="text-[1.05rem] font-extrabold">Dolomites</h3><p class="text-[.84rem] text-[#475569]">Italy · mountain huts</p><p class="mt-1.5 text-[.84rem] font-bold">From €85 a night</p><a class="mt-2.5 self-start text-[.85rem] font-extrabold text-[#1d4ed8] no-underline hover:underline focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#1d4ed8]" href="#">See stays<span class="sr-only"> in the Dolomites</span></a></div></li>
      <li class="rg-card relative flex flex-[0_0_200px] snap-start flex-col overflow-hidden rounded-[18px] bg-white shadow-[0_0_0_1px_rgba(15,23,42,.08),0_22px_34px_-24px_rgba(15,23,42,.55)] group-[.is-3d]:absolute group-[.is-3d]:inset-0 group-[.is-3d]:backface-hidden group-[.is-3d]:[transform:rotateY(calc(var(--k)*var(--step)*1deg))_translateZ(var(--r))]" style="--art:linear-gradient(170deg,transparent 60%,#9a3412 60.5%),radial-gradient(70% 90% at 25% 100%,#b91c1c 50%,transparent 50.5%),linear-gradient(#fdba74,#ffedd5)"><div class="h-[124px] [background:var(--art)]" aria-hidden="true"></div>
        <div class="flex flex-1 flex-col gap-0.5 px-4 pt-3.5 pb-4"><h3 class="text-[1.05rem] font-extrabold">Wadi Rum</h3><p class="text-[.84rem] text-[#475569]">Jordan · desert camps</p><p class="mt-1.5 text-[.84rem] font-bold">From €70 a night</p><a class="mt-2.5 self-start text-[.85rem] font-extrabold text-[#1d4ed8] no-underline hover:underline focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#1d4ed8]" href="#">See stays<span class="sr-only"> in Wadi Rum</span></a></div></li>
    </ul>
  </div>
  <div class="mt-1.5 hidden items-center justify-center gap-4 group-[.is-3d]:flex">
    <button class="grid size-11 cursor-pointer place-items-center rounded-full border border-[#94a3b8] bg-white text-[#0f172a] transition-colors duration-200 hover:border-[#0f172a] hover:bg-[#f8fafc] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#1d4ed8] motion-reduce:transition-none" type="button" data-dir="-1" aria-label="Previous destination"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 5l-7 7 7 7"/></svg></button>
    <p class="rg-status min-w-[190px] text-center text-[.9rem] font-bold" aria-live="polite"></p>
    <button class="grid size-11 cursor-pointer place-items-center rounded-full border border-[#94a3b8] bg-white text-[#0f172a] transition-colors duration-200 hover:border-[#0f172a] hover:bg-[#f8fafc] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#1d4ed8] motion-reduce:transition-none" type="button" data-dir="1" aria-label="Next destination"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9 5l7 7-7 7"/></svg></button>
  </div>
</section></div>

<div><span class="mb-[18px] block text-[.72rem] tracking-[.12em] text-[#475569] uppercase">Drum picker</span>
<fieldset class="group grid w-fit max-w-full min-w-0 grid-cols-[auto_auto] items-center justify-start gap-x-7 gap-y-[18px] rounded-[22px] bg-white px-[26px] py-6 shadow-[0_22px_34px_-26px_rgba(15,23,42,.5)]" data-drum>
  <legend class="float-left col-span-full text-[1.05rem] font-extrabold">Pick-up time at Harbour Street</legend>
  <div class="col-span-full group-[.is-3d]:col-auto group-[.is-3d]:perspective-[600px] group-[.is-3d]:[mask-image:linear-gradient(transparent,#000_30%,#000_70%,transparent)]">
    <ol class="dr-drum flex flex-wrap gap-2 transition-transform duration-600 ease-[cubic-bezier(.3,.8,.25,1)] group-[.is-3d]:relative group-[.is-3d]:my-[62px] group-[.is-3d]:block group-[.is-3d]:h-[46px] group-[.is-3d]:w-[150px] group-[.is-3d]:transform-3d group-[.is-3d]:[transform:translateZ(calc(var(--r)*-1))_rotateX(calc(var(--i,0)*var(--step)*1deg))] motion-reduce:transition-none">
      <li class="group-[.is-3d]:absolute group-[.is-3d]:inset-0 group-[.is-3d]:backface-hidden group-[.is-3d]:[transform:rotateX(calc(var(--k)*var(--step)*-1deg))_translateZ(var(--r))]"><label class="block cursor-pointer group-[.is-3d]:h-full"><input class="peer absolute size-px opacity-0" type="radio" name="slot" value="09:00"><span class="block rounded-full border border-[#94a3b8] px-3.5 py-2 text-[.95rem] font-bold text-[#475569] tabular-nums peer-checked:border-[#0f172a] peer-checked:bg-[#0f172a] peer-checked:text-white peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-[#1d4ed8] group-[.is-3d]:grid group-[.is-3d]:h-full group-[.is-3d]:place-items-center group-[.is-3d]:rounded-xl group-[.is-3d]:border-0 group-[.is-3d]:bg-[#f1f5f9] group-[.is-3d]:p-0 group-[.is-3d]:text-xl group-[.is-3d]:text-[#0f172a] group-[.is-3d]:peer-checked:bg-[#0f172a] group-[.is-3d]:peer-checked:text-white">09:00</span></label></li>
      <li class="group-[.is-3d]:absolute group-[.is-3d]:inset-0 group-[.is-3d]:backface-hidden group-[.is-3d]:[transform:rotateX(calc(var(--k)*var(--step)*-1deg))_translateZ(var(--r))]"><label class="block cursor-pointer group-[.is-3d]:h-full"><input class="peer absolute size-px opacity-0" type="radio" name="slot" value="09:30"><span class="block rounded-full border border-[#94a3b8] px-3.5 py-2 text-[.95rem] font-bold text-[#475569] tabular-nums peer-checked:border-[#0f172a] peer-checked:bg-[#0f172a] peer-checked:text-white peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-[#1d4ed8] group-[.is-3d]:grid group-[.is-3d]:h-full group-[.is-3d]:place-items-center group-[.is-3d]:rounded-xl group-[.is-3d]:border-0 group-[.is-3d]:bg-[#f1f5f9] group-[.is-3d]:p-0 group-[.is-3d]:text-xl group-[.is-3d]:text-[#0f172a] group-[.is-3d]:peer-checked:bg-[#0f172a] group-[.is-3d]:peer-checked:text-white">09:30</span></label></li>
      <li class="group-[.is-3d]:absolute group-[.is-3d]:inset-0 group-[.is-3d]:backface-hidden group-[.is-3d]:[transform:rotateX(calc(var(--k)*var(--step)*-1deg))_translateZ(var(--r))]"><label class="block cursor-pointer group-[.is-3d]:h-full"><input class="peer absolute size-px opacity-0" type="radio" name="slot" value="10:00" checked><span class="block rounded-full border border-[#94a3b8] px-3.5 py-2 text-[.95rem] font-bold text-[#475569] tabular-nums peer-checked:border-[#0f172a] peer-checked:bg-[#0f172a] peer-checked:text-white peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-[#1d4ed8] group-[.is-3d]:grid group-[.is-3d]:h-full group-[.is-3d]:place-items-center group-[.is-3d]:rounded-xl group-[.is-3d]:border-0 group-[.is-3d]:bg-[#f1f5f9] group-[.is-3d]:p-0 group-[.is-3d]:text-xl group-[.is-3d]:text-[#0f172a] group-[.is-3d]:peer-checked:bg-[#0f172a] group-[.is-3d]:peer-checked:text-white">10:00</span></label></li>
      <li class="group-[.is-3d]:absolute group-[.is-3d]:inset-0 group-[.is-3d]:backface-hidden group-[.is-3d]:[transform:rotateX(calc(var(--k)*var(--step)*-1deg))_translateZ(var(--r))]"><label class="block cursor-pointer group-[.is-3d]:h-full"><input class="peer absolute size-px opacity-0" type="radio" name="slot" value="10:30"><span class="block rounded-full border border-[#94a3b8] px-3.5 py-2 text-[.95rem] font-bold text-[#475569] tabular-nums peer-checked:border-[#0f172a] peer-checked:bg-[#0f172a] peer-checked:text-white peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-[#1d4ed8] group-[.is-3d]:grid group-[.is-3d]:h-full group-[.is-3d]:place-items-center group-[.is-3d]:rounded-xl group-[.is-3d]:border-0 group-[.is-3d]:bg-[#f1f5f9] group-[.is-3d]:p-0 group-[.is-3d]:text-xl group-[.is-3d]:text-[#0f172a] group-[.is-3d]:peer-checked:bg-[#0f172a] group-[.is-3d]:peer-checked:text-white">10:30</span></label></li>
      <li class="group-[.is-3d]:absolute group-[.is-3d]:inset-0 group-[.is-3d]:backface-hidden group-[.is-3d]:[transform:rotateX(calc(var(--k)*var(--step)*-1deg))_translateZ(var(--r))]"><label class="block cursor-pointer group-[.is-3d]:h-full"><input class="peer absolute size-px opacity-0" type="radio" name="slot" value="11:00"><span class="block rounded-full border border-[#94a3b8] px-3.5 py-2 text-[.95rem] font-bold text-[#475569] tabular-nums peer-checked:border-[#0f172a] peer-checked:bg-[#0f172a] peer-checked:text-white peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-[#1d4ed8] group-[.is-3d]:grid group-[.is-3d]:h-full group-[.is-3d]:place-items-center group-[.is-3d]:rounded-xl group-[.is-3d]:border-0 group-[.is-3d]:bg-[#f1f5f9] group-[.is-3d]:p-0 group-[.is-3d]:text-xl group-[.is-3d]:text-[#0f172a] group-[.is-3d]:peer-checked:bg-[#0f172a] group-[.is-3d]:peer-checked:text-white">11:00</span></label></li>
      <li class="group-[.is-3d]:absolute group-[.is-3d]:inset-0 group-[.is-3d]:backface-hidden group-[.is-3d]:[transform:rotateX(calc(var(--k)*var(--step)*-1deg))_translateZ(var(--r))]"><label class="block cursor-pointer group-[.is-3d]:h-full"><input class="peer absolute size-px opacity-0" type="radio" name="slot" value="11:30"><span class="block rounded-full border border-[#94a3b8] px-3.5 py-2 text-[.95rem] font-bold text-[#475569] tabular-nums peer-checked:border-[#0f172a] peer-checked:bg-[#0f172a] peer-checked:text-white peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-[#1d4ed8] group-[.is-3d]:grid group-[.is-3d]:h-full group-[.is-3d]:place-items-center group-[.is-3d]:rounded-xl group-[.is-3d]:border-0 group-[.is-3d]:bg-[#f1f5f9] group-[.is-3d]:p-0 group-[.is-3d]:text-xl group-[.is-3d]:text-[#0f172a] group-[.is-3d]:peer-checked:bg-[#0f172a] group-[.is-3d]:peer-checked:text-white">11:30</span></label></li>
      <li class="group-[.is-3d]:absolute group-[.is-3d]:inset-0 group-[.is-3d]:backface-hidden group-[.is-3d]:[transform:rotateX(calc(var(--k)*var(--step)*-1deg))_translateZ(var(--r))]"><label class="block cursor-pointer group-[.is-3d]:h-full"><input class="peer absolute size-px opacity-0" type="radio" name="slot" value="12:00"><span class="block rounded-full border border-[#94a3b8] px-3.5 py-2 text-[.95rem] font-bold text-[#475569] tabular-nums peer-checked:border-[#0f172a] peer-checked:bg-[#0f172a] peer-checked:text-white peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-[#1d4ed8] group-[.is-3d]:grid group-[.is-3d]:h-full group-[.is-3d]:place-items-center group-[.is-3d]:rounded-xl group-[.is-3d]:border-0 group-[.is-3d]:bg-[#f1f5f9] group-[.is-3d]:p-0 group-[.is-3d]:text-xl group-[.is-3d]:text-[#0f172a] group-[.is-3d]:peer-checked:bg-[#0f172a] group-[.is-3d]:peer-checked:text-white">12:00</span></label></li>
      <li class="group-[.is-3d]:absolute group-[.is-3d]:inset-0 group-[.is-3d]:backface-hidden group-[.is-3d]:[transform:rotateX(calc(var(--k)*var(--step)*-1deg))_translateZ(var(--r))]"><label class="block cursor-pointer group-[.is-3d]:h-full"><input class="peer absolute size-px opacity-0" type="radio" name="slot" value="12:30"><span class="block rounded-full border border-[#94a3b8] px-3.5 py-2 text-[.95rem] font-bold text-[#475569] tabular-nums peer-checked:border-[#0f172a] peer-checked:bg-[#0f172a] peer-checked:text-white peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-[#1d4ed8] group-[.is-3d]:grid group-[.is-3d]:h-full group-[.is-3d]:place-items-center group-[.is-3d]:rounded-xl group-[.is-3d]:border-0 group-[.is-3d]:bg-[#f1f5f9] group-[.is-3d]:p-0 group-[.is-3d]:text-xl group-[.is-3d]:text-[#0f172a] group-[.is-3d]:peer-checked:bg-[#0f172a] group-[.is-3d]:peer-checked:text-white">12:30</span></label></li>
    </ol>
  </div>
  <div class="hidden flex-col gap-2.5 group-[.is-3d]:flex">
    <button class="grid size-11 cursor-pointer place-items-center rounded-full border border-[#94a3b8] bg-white text-[#0f172a] transition-colors duration-200 hover:border-[#0f172a] hover:bg-[#f8fafc] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#1d4ed8] motion-reduce:transition-none" type="button" data-dir="-1" aria-label="Earlier time"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 15l7-7 7 7"/></svg></button>
    <button class="grid size-11 cursor-pointer place-items-center rounded-full border border-[#94a3b8] bg-white text-[#0f172a] transition-colors duration-200 hover:border-[#0f172a] hover:bg-[#f8fafc] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#1d4ed8] motion-reduce:transition-none" type="button" data-dir="1" aria-label="Later time"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 9l7 7 7-7"/></svg></button>
  </div>
  <output class="dr-out col-span-full text-[.92rem] text-[#475569]" aria-live="polite">Your order will be ready at <b class="text-[#0f172a]">10:00</b>.</output>
</fieldset></div>
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);
  });
})();

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.