← Cards & images

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.

Northern Lights
Night Drive
Night Drive
Mountain Lake
Bloom
Bloom
Dunes at Dusk
Tides
Tides
Neon
Neon
Blue Hour

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-carousel-ring and an aria-label to 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-intervalTime between turns. Default 2600 ms. 0 turns only when asked.
data-tiltHow far the ring leans towards you, in degrees. Default 8.
data-gapSpace 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.

1 · Snippet
<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>
2 · Your HTML
<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>