← Cards & images

Card Flip

A card turns over in 3D to show its back, landing with a little overshoot. Flip it on click, on hover, or let it turn on a timer.

Click to flip
Hello from the back

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-card-flip to a container with two children: the front, then the back.

Options

Add any of these attributes to the same element.

data-triggerclick (default) or hover.
data-axisy (default) turns sideways; x tips forwards.
data-autoplayMilliseconds: flip on a timer until someone interacts.
data-durationTime for a flip. Default 800 ms.

With the click trigger the card works as a toggle button, including Enter and Space. From code, animacious.flip(card) turns it over.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Card Flip: a card turns over in 3D to show its back, with a little
    // overshoot as it lands. Flip it on click, on hover, or on a timer.
    // Usage:
    //   <div data-card-flip>
    //     <div class="front">Front</div>
    //     <div class="back">Back</div>
    //   </div>
    // The first child is the front, the second the back.
    // Optional: data-trigger ("click", default, or "hover"), data-axis ("y",
    // default, turns sideways; "x" tips forwards), data-autoplay (ms between
    // flips, until someone interacts), data-duration (ms, default 800).
    // Script use: animacious.flip(card) turns it over from code.
    var DEFAULTS = { duration: 800 };

    var css =
      '.cfx{perspective:1200px}' +
      '.cfx-inner{display:grid;height:100%;transform-style:preserve-3d}' +
      '.cfx-inner>*{grid-area:1/1;backface-visibility:hidden;-webkit-backface-visibility:hidden}' +
      '.cfx[data-trigger="hover"]{cursor:default}' +
      '.cfx:not([data-trigger="hover"]){cursor:pointer}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function setup(el) {
      var d = el.dataset;
      var faces = Array.from(el.children);
      if (faces.length < 2) return;
      el.classList.add('cfx');
      var inner = document.createElement('div');
      inner.className = 'cfx-inner';
      faces.slice(0, 2).forEach(function (f) { inner.appendChild(f); });
      el.appendChild(inner);
      var prop = d.axis === 'x' ? 'rotateX' : 'rotateY';
      var back = {};
      back[prop] = 180;
      anime.utils.set(faces[1], back);
      var s = el._cfx = { inner: inner, faces: faces, prop: prop, flipped: false, timer: null };
      faces[1].setAttribute('aria-hidden', 'true');

      if (d.trigger === 'hover') {
        el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { stop(el); flip(el, true); } });
        el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') flip(el, false); });
        el.addEventListener('focusin', function () { flip(el, true); });
        el.addEventListener('focusout', function () { flip(el, false); });
        // Touch screens have no hover: a tap flips it instead.
        var touch = false;
        el.addEventListener('pointerdown', function (e) { touch = e.pointerType === 'touch'; });
        el.addEventListener('click', function () { if (touch) { stop(el); flip(el); } });
      } else {
        if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
        el.setAttribute('role', 'button');
        el.setAttribute('aria-pressed', 'false');
        if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Flip card');
        el.addEventListener('click', function (e) {
          if (e.target.closest('a,button,input,select,textarea') && e.target !== el) return;
          stop(el);
          flip(el);
        });
        el.addEventListener('keydown', function (e) {
          if (e.target !== el || (e.key !== 'Enter' && e.key !== ' ')) return;
          e.preventDefault();
          stop(el);
          flip(el);
        });
      }
      if (d.autoplay !== undefined && d.autoplay !== 'false' && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        s.timer = setInterval(function () { if (!el.closest('[hidden]')) flip(el); }, +d.autoplay || 2400);
      }
    }

    function stop(el) {
      if (el._cfx && el._cfx.timer) { clearInterval(el._cfx.timer); el._cfx.timer = null; }
    }

    function flip(el, to) {
      var s = el._cfx;
      if (!s) return;
      var next = to === undefined ? !s.flipped : !!to;
      if (next === s.flipped) return;
      s.flipped = next;
      s.faces[0].setAttribute('aria-hidden', next);
      s.faces[1].setAttribute('aria-hidden', !next);
      if (el.getAttribute('role') === 'button') el.setAttribute('aria-pressed', next);
      var props = { duration: +el.dataset.duration || DEFAULTS.duration, ease: 'outBack(1.4)' };
      props[s.prop] = next ? 180 : 0;
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) props.duration = 0;
      else props.scale = [1, 0.94, 1];
      anime.animate(s.inner, props);
    }

    function init() {
      document.querySelectorAll('[data-card-flip]').forEach(function (el) {
        if (el._cfx) return;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.flip = function (el) { stop(el); flip(el); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-card-flip>
  <div class="front">Front</div>
  <div class="back">Back</div>
</div>