← Cards & images

Coverflow Carousel

The current item faces you in the middle while the rest angle away in 3D on either side, like flicking through a box of records. Use the buttons, the arrow keys, drag or swipe, or click an item at the side.

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

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-coverflow-carousel and an aria-label to a container whose children are the items, and give the items a width.

Options

Add any of these attributes to the same element.

data-startIndex of the item shown first, from 0. Default the middle one.
data-angleHow far the side items turn, in degrees. Default 50.
data-autoplayMilliseconds between moves. It steps along and back, pauses under the pointer and stops once someone uses it.
data-controls="false"No previous and next buttons.

It’s announced as a carousel; the item in the middle is the one screen readers and the Tab key see, and moving is announced politely. From code, animacious.coverflow(element, 2) moves to an item.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Coverflow Carousel: the current item faces you in the middle while the
    // others angle away in 3D on either side, like flicking through records.
    // Move with the arrow buttons, the arrow keys, by dragging or swiping, or
    // by clicking an item at the side.
    // Usage:
    //   <div data-coverflow-carousel aria-label="Our work">
    //     <figure><img src="one.jpg" alt="…"><figcaption>One</figcaption></figure>
    //     <figure>…</figure>
    //   </div>
    // Each child is an item; give them a width. Optional: data-start (index of
    // the first item shown, default the middle one), data-angle (how far side
    // items turn, in degrees, default 50), data-autoplay (ms between moves;
    // it goes there and back and stops when someone uses it),
    // data-controls="false" (no buttons).
    // Script use: animacious.coverflow(element, index) moves it to an item.
    var DEFAULTS = { angle: 50, duration: 650, visible: 3 };
    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-coverflow-carousel]{display:grid;justify-items:center;align-items:center;perspective:1100px;touch-action:pan-y;user-select:none;-webkit-user-select:none}' +
      '[data-coverflow-carousel]>*{grid-area:1/1}' +
      '[data-coverflow-carousel]>:not(.cvf-nav){cursor:pointer}' +
      '[data-coverflow-carousel]>.cvf-current{cursor:default}' +
      '[data-coverflow-carousel]:focus-visible{outline:none}' +
      '[data-coverflow-carousel]:focus-visible>.cvf-current{outline:3px solid #a855f7;outline-offset:4px}' +
      '.cvf-nav{grid-area:2/1!important;display:flex;align-items:center;gap:14px;margin-top:22px;font:600 14px/1 system-ui,sans-serif;color:inherit}' +
      '.cvf-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}' +
      '.cvf-nav button:last-of-type svg{transform:scaleX(-1)}' +
      '.cvf-nav button:disabled{opacity:.4;cursor:default}' +
      '.cvf-nav button:focus-visible{outline:3px solid #a855f7;outline-offset:2px}' +
      '.cvf-count{min-width:3.5em;text-align:center;font-variant-numeric:tabular-nums}' +
      '.cvf-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 setup(el) {
      var d = el.dataset;
      var items = Array.from(el.children);
      var n = items.length;
      if (n < 2) return;
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var angle = d.angle !== undefined ? +d.angle : DEFAULTS.angle;
      var inThumb = !!el.closest('[aria-hidden="true"]');
      var current = d.start !== undefined ? Math.max(0, Math.min(n - 1, +d.start)) : Math.floor((n - 1) / 2);
      var dir = 1, timer = null, stopped = false, paused = false;

      if (!inThumb) {
        el.setAttribute('role', 'region');
        el.setAttribute('aria-roledescription', 'carousel');
        if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
      }
      items.forEach(function (item, i) {
        item.setAttribute('role', 'group');
        item.setAttribute('aria-roledescription', 'slide');
        if (!item.getAttribute('aria-label')) item.setAttribute('aria-label', i + 1 + ' of ' + n);
        anime.utils.set(item, { filter: 'brightness(1)' });
      });

      var prev, next, count, live;
      if (d.controls !== 'false' && !inThumb) {
        var nav = document.createElement('div');
        nav.className = 'cvf-nav';
        nav.innerHTML =
          '<button type="button" aria-label="Previous">' + CHEVRON + '</button>' +
          '<span class="cvf-count" aria-hidden="true"></span>' +
          '<button type="button" aria-label="Next">' + CHEVRON + '</button>' +
          '<span class="cvf-sr" aria-live="polite"></span>';
        el.appendChild(nav);
        prev = nav.children[0];
        count = nav.children[1];
        next = nav.children[2];
        live = nav.children[3];
        prev.addEventListener('click', function () { stop(); go(current - 1); });
        next.addEventListener('click', function () { stop(); go(current + 1); });
      }

      // Where an item sits, from its distance to the centre (can be fractional while dragging).
      function pose(o, w) {
        var a = Math.abs(o), s = o < 0 ? -1 : 1, t = Math.min(a, 1), far = Math.max(0, a - 1);
        return {
          x: s * (t * w * 0.62 + far * w * 0.34),
          rotateY: -s * t * angle,
          translateZ: -(t * w * 0.4 + far * w * 0.14),
          filter: 'brightness(' + (1 - Math.min(a, DEFAULTS.visible) * 0.14).toFixed(3) + ')',
          opacity: Math.max(0, Math.min(1, DEFAULTS.visible + 1 - a))
        };
      }

      function render(pos, animate) {
        var w = items[Math.round(pos)].offsetWidth || 200;
        items.forEach(function (item, i) {
          var o = i - pos, props = pose(o, w);
          item.style.zIndex = 100 - Math.round(Math.abs(o) * 10);
          if (!animate || reduced) return anime.utils.set(item, props);
          props.duration = DEFAULTS.duration;
          props.ease = 'out(3)';
          anime.animate(item, props);
        });
      }

      function update() {
        items.forEach(function (item, i) {
          var on = i === current;
          item.classList.toggle('cvf-current', on);
          item.setAttribute('aria-hidden', on ? 'false' : 'true');
          // Only the current item's links and buttons are in the tab order.
          item.querySelectorAll('a,button,input,select,textarea,[tabindex]').forEach(function (f) {
            if (on) { if (f.dataset.cvfTab !== undefined) { f.setAttribute('tabindex', f.dataset.cvfTab); delete f.dataset.cvfTab; } }
            else if (f.dataset.cvfTab === undefined) { f.dataset.cvfTab = f.getAttribute('tabindex') || '0'; f.setAttribute('tabindex', '-1'); }
          });
        });
        if (prev) {
          prev.disabled = current === 0;
          next.disabled = current === n - 1;
          count.textContent = current + 1 + ' / ' + n;
        }
      }

      function go(i, quiet) {
        i = Math.max(0, Math.min(n - 1, i));
        if (i === current) return render(current, true);
        current = i;
        update();
        render(current, true);
        if (live && !quiet) {
          var cap = items[i].querySelector('figcaption,h2,h3,[alt]');
          live.textContent = 'Item ' + (i + 1) + ' of ' + n + (cap ? ': ' + (cap.getAttribute('alt') || cap.textContent.trim()) : '');
        }
      }
      el._cvf = function (i) { stop(); go(i); };

      function stop() { stopped = true; clearInterval(timer); timer = null; }

      update();
      render(current, false);
      if (window.ResizeObserver) new ResizeObserver(function () { render(current, false); }).observe(el);

      el.addEventListener('keydown', function (e) {
        if (e.target !== el) return;
        var to = e.key === 'ArrowLeft' ? current - 1 : e.key === 'ArrowRight' ? current + 1 : e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : null;
        if (to === null) return;
        e.preventDefault();
        stop();
        go(to);
      });

      // Drag or swipe: the items follow the pointer, then settle on the nearest.
      // A click on an item at the side brings it to the middle. (Not in a
      // decorative copy, e.g. inside a link: clicks there are the link's.)
      if (!inThumb) {
        var down = null, dragged = false;
        el.addEventListener('pointerdown', function (e) {
          if (e.target.closest('.cvf-nav') || e.button > 0) return;
          down = { x: e.clientX, from: current, id: e.pointerId };
          dragged = false;
        });
        el.addEventListener('pointermove', function (e) {
          if (!down) return;
          var dx = e.clientX - down.x;
          if (!dragged && Math.abs(dx) < 6) return;
          if (!dragged) { dragged = true; stop(); el.setPointerCapture(down.id); }
          var w = items[current].offsetWidth || 200;
          render(Math.max(-0.4, Math.min(n - 0.6, down.from - dx / (w * 0.62))), false);
        });
        var release = function (e) {
          if (!down) return;
          if (dragged && e.type === 'pointercancel') go(current);
          else if (dragged) go(Math.round(down.from - (e.clientX - down.x) / ((items[current].offsetWidth || 200) * 0.62)));
          down = null;
        };
        el.addEventListener('pointerup', release);
        el.addEventListener('pointercancel', release);
        el.addEventListener('click', function (e) {
          if (dragged) { dragged = false; e.preventDefault(); return; }
          var item = items.find(function (it) { return it.contains(e.target); });
          if (!item || item.classList.contains('cvf-current')) return;
          e.preventDefault();
          stop();
          go(items.indexOf(item));
        }, true);
      }

      // Autoplay: step along and back again, paused under the pointer or focus.
      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced) {
        var every = +d.autoplay || 2200;
        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; });
        new IntersectionObserver(function (entries) {
          clearInterval(timer);
          timer = null;
          if (!entries[0].isIntersecting || stopped) return;
          timer = setInterval(function () {
            if (paused || el.closest('[hidden]')) return;
            if (current + dir < 0 || current + dir > n - 1) dir = -dir;
            go(current + dir, true);
          }, every);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-coverflow-carousel]').forEach(function (el) {
        if (el._cvf) return;
        setup(el);
        el._cvf = el._cvf || true;
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.coverflow = function (el, i) { if (el && typeof el._cvf === 'function') el._cvf(i); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-coverflow-carousel aria-label="Our work">
  <figure><img src="one.jpg" alt="…"><figcaption>One</figcaption></figure>
  <figure><img src="two.jpg" alt="…"><figcaption>Two</figcaption></figure>
  <figure><img src="three.jpg" alt="…"><figcaption>Three</figcaption></figure>
</div>