← Cards & images

Card Fan

A neat pile of cards fans out like a hand of playing cards when the pointer comes over it, and the card under the pointer lifts up for a closer look. Move away and they gather back into a pile.

Summer12 photos
TripAlbum

Point at the stack, then at a card

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-card-fan to a container whose children are the cards, and give the cards a size.

Options

Add any of these attributes to the same element.

data-angleDegrees from the first card to the last when fanned. Default 40.
data-spreadHow far apart they spread, as a share of a card’s width. Default 0.3.
data-trigger="click"Fan out and back on click (or Enter and Space) instead of on hover.
data-autoplayMilliseconds: fan out and back on a timer while in view. A real pointer pauses it.

It fans out when it gets keyboard focus too, and on touch screens a tap toggles it.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Card Fan: a neat stack of cards fans out like a hand of playing cards
    // when the pointer comes over it (or it gets keyboard focus), and the card
    // under the pointer lifts up. It gathers back into a stack on leave.
    // Usage:
    //   <div data-card-fan>
    //     <img src="one.jpg" alt="…">
    //     <img src="two.jpg" alt="…">
    //     <img src="three.jpg" alt="…">
    //   </div>
    // Each child is a card. Optional: data-angle (degrees from the first card
    // to the last, default 40), data-spread (how far apart, as a share of a
    // card's width, default 0.3), data-trigger="click" (fan out on click
    // instead), data-autoplay (ms: fan out and back on a timer until someone
    // interacts).
    var DEFAULTS = { angle: 40, spread: 0.3, duration: 650 };

    var css =
      '[data-card-fan]{display:grid;justify-items:center;align-items:end}' +
      '[data-card-fan]>*{grid-area:1/1;transform-origin:50% 140%;will-change:transform}' +
      '.cfn-click{cursor:pointer}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function setup(el) {
      var d = el.dataset;
      var cards = Array.from(el.children);
      if (!cards.length) return;
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var angle = d.angle !== undefined ? +d.angle : DEFAULTS.angle;
      var spread = d.spread !== undefined ? +d.spread : DEFAULTS.spread;
      var mid = (cards.length - 1) / 2;
      var step = cards.length > 1 ? angle / (cards.length - 1) : 0;
      var open = false, lifted = null, timer = null, stopped = false, paused = false;
      var inThumb = !!el.closest('[aria-hidden="true"]');

      function place(card, i, now) {
        var o = i - mid, cw = card.offsetWidth;
        var props = open
          ? { rotate: o * step, x: o * cw * spread, y: card === lifted ? -cw * 0.16 : 0, scale: card === lifted ? 1.05 : 1 }
          // Stacked: a tiny untidy offset, so it reads as a pile.
          : { rotate: o * 2.2, x: o * 1.5, y: -Math.abs(o) * 1.5, scale: 1 };
        if (now || reduced) return anime.utils.set(card, props);
        props.duration = DEFAULTS.duration;
        props.ease = open ? 'outBack(1.3)' : 'out(4)';
        // Fan out from the middle; gather in from the edges.
        props.delay = (open ? Math.abs(o) : mid - Math.abs(o)) * 35;
        anime.animate(card, props);
      }

      function layout(now) { cards.forEach(function (c, i) { place(c, i, now); }); }

      function set(next) {
        next = !!next;
        if (next === open) return;
        open = next;
        if (!open) lifted = null;
        if (d.trigger === 'click') el.setAttribute('aria-expanded', open);
        layout(false);
      }

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

      layout(true);
      cards.forEach(function (card, i) {
        card.addEventListener('pointerenter', function (e) {
          if (e.pointerType === 'touch' || !open) return;
          lifted = card;
          place(card, i, false);
        });
        card.addEventListener('pointerleave', function () {
          if (lifted !== card) return;
          lifted = null;
          if (open) place(card, i, false);
        });
      });

      if (d.trigger === 'click') {
        el.classList.add('cfn-click');
        if (!inThumb) {
          if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
          el.setAttribute('role', 'button');
          el.setAttribute('aria-expanded', 'false');
          if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Fan out the cards');
        }
        el.addEventListener('click', function () { stop(); set(!open); });
        el.addEventListener('keydown', function (e) {
          if (e.target !== el || (e.key !== 'Enter' && e.key !== ' ')) return;
          e.preventDefault();
          stop();
          set(!open);
        });
      } else {
        // A real pointer pauses the autoplay while it's over the cards.
        el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { paused = true; set(true); } });
        el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') { paused = false; set(false); } });
        // Touch screens have no hover: a tap fans them out or gathers them.
        el.addEventListener('pointerup', function (e) { if (e.pointerType === 'touch') { stop(); set(!open); } });
        // Keyboard users get the fan when focus lands in it.
        if (!inThumb && !el.querySelector('a,button,input,select,textarea,[tabindex]') && !el.hasAttribute('tabindex')) {
          el.tabIndex = 0;
          if (!el.getAttribute('role')) el.setAttribute('role', 'group');
        }
        el.addEventListener('focusin', function () { stop(); set(true); });
        el.addEventListener('focusout', function (e) { if (!el.contains(e.relatedTarget)) set(false); });
      }

      if (window.ResizeObserver) new ResizeObserver(function () { layout(true); }).observe(el);

      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced) {
        var every = +d.autoplay || 1800;
        new IntersectionObserver(function (entries) {
          clearInterval(timer);
          timer = null;
          if (!entries[0].isIntersecting || stopped) return;
          timer = setInterval(function () {
            if (paused || el.closest('[hidden]')) return;
            // Lift a card part-way through, as a pointer would.
            set(!open);
            if (open) setTimeout(function () {
              if (!open || paused) return;
              var i = Math.floor(Math.random() * cards.length);
              lifted = cards[i];
              place(lifted, i, false);
            }, every * 0.45);
          }, every);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-card-fan]').forEach(function (el) {
        if (el._cfn) return;
        el._cfn = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-card-fan>
  <img src="one.jpg" alt="Beach" width="150" height="200">
  <img src="two.jpg" alt="Forest" width="150" height="200">
  <img src="three.jpg" alt="City" width="150" height="200">
</div>