← Text effects

Character 3D Flip

Letters turn over like cards, rippling out from the middle of the word, to show a back face in another colour, or another letter. Play it on scroll, on a loop, or when the pointer is over the text.

Flip it

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-character-flip to any heading, link or text element.

Options

Add any of these attributes to the same element.

data-triggerview (default) flips when scrolled into view. hover flips while the pointer is over the text.
data-loopKeep flipping back and forth (with the view trigger).
data-back-colorColour of the back faces. Default #a855f7.
data-backDifferent text for the back, letter by letter, e.g. data-back="NEW" on “OLD”.
data-fromWhere the ripple starts: center (default), first or last.
data-holdHow long each side stays when looping. Default 1500 ms.
data-durationHow long each letter takes to turn. Default 700 ms.
data-staggerGap between letters. Default 45 ms.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Character 3D Flip: letters turn over like cards, rippling out from the
    // middle of the word, to show a back face in another colour (or letter).
    // Usage: <h1 data-character-flip>Flip it</h1>
    // Optional: data-trigger ("view", the default, or "hover"), data-loop,
    // data-back-color (default #a855f7), data-back (other text for the back),
    // data-from ("center", "first", "last"), data-hold, data-duration,
    // data-stagger (all ms).
    var DEFAULTS = { duration: 700, stagger: 45, hold: 1500, backColor: '#a855f7', from: 'center' };

    var css =
      '.chf-word{display:inline-block;white-space:nowrap}' +
      // Both faces share one grid cell, so a letter is as wide as its wider face.
      '.chf-char{display:inline-grid;transform-style:preserve-3d}' +
      '.chf-face{grid-area:1/1;text-align:center;backface-visibility:hidden;-webkit-backface-visibility:hidden}' +
      '.chf-back{transform:rotateY(180deg)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function split(el) {
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      var back = Array.from((el.dataset.back || '').replace(/\s+/g, ' ').trim());
      var color = el.dataset.backColor || DEFAULTS.backColor;
      el.setAttribute('aria-label', text);
      el.textContent = '';
      var chars = [];
      var i = 0;
      text.split(' ').forEach(function (word, wi) {
        if (wi) { el.appendChild(document.createTextNode(' ')); i++; }
        var w = document.createElement('span');
        w.className = 'chf-word';
        w.setAttribute('aria-hidden', 'true');
        Array.from(word).forEach(function (c) {
          var ch = document.createElement('span');
          ch.className = 'chf-char';
          var front = document.createElement('span');
          front.className = 'chf-face';
          front.textContent = c;
          var b = document.createElement('span');
          b.className = 'chf-face chf-back';
          b.style.color = color;
          b.textContent = back[i] && back[i] !== ' ' ? back[i] : c;
          ch.appendChild(front);
          ch.appendChild(b);
          w.appendChild(ch);
          chars.push(ch);
          i++;
        });
        el.appendChild(w);
      });
      return chars;
    }

    function setup(el) {
      var chars = split(el);
      var d = el.dataset;
      var opts = {
        duration: +d.duration || DEFAULTS.duration,
        stagger: +d.stagger || DEFAULTS.stagger,
        hold: +d.hold || DEFAULTS.hold,
        from: d.from || DEFAULTS.from
      };
      // Each letter has its own perspective so it turns about its own centre.
      anime.utils.set(chars, { perspective: '500px' });
      var anim = null;
      function flipTo(side, wait, then) {
        if (anim) anim.pause();
        anim = anime.animate(chars, {
          rotateY: side * 180,
          duration: opts.duration,
          delay: anime.stagger(opts.stagger, { from: opts.from, start: wait || 0 }),
          ease: 'outBack(1.4)',
          onComplete: then
        });
      }
      return { el: el, chars: chars, opts: opts, flipTo: flipTo };
    }

    function playInView(f) {
      var loop = f.el.dataset.loop !== undefined && f.el.dataset.loop !== 'false';
      var side = 0;
      (function next() {
        side = side ? 0 : 1;
        f.flipTo(side, side === 1 && !loop ? 0 : f.opts.hold, loop ? next : undefined);
      })();
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          playInView(e.target._chf);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-character-flip]').forEach(function (el) {
        if (el._chf) return;
        var f = (el._chf = setup(el));
        if (reduced) return;
        if (el.dataset.trigger === 'hover') {
          el.addEventListener('mouseenter', function () { f.flipTo(1); });
          el.addEventListener('mouseleave', function () { f.flipTo(0); });
          el.addEventListener('focus', function () { f.flipTo(1); });
          el.addEventListener('blur', function () { f.flipTo(0); });
        } else {
          io.observe(el);
        }
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-character-flip data-loop>Flip it</h1>

<!-- flip on hover, different letters on the back: -->
<a href="/sale" data-character-flip data-trigger="hover" data-back="SHOP">SALE</a>