← Entrances

Fold In

The element unfolds from a crease across its middle. Both halves start folded towards you, almost edge-on, and open out flat like a greetings card, brightening as they turn to face you.

You’re invitedRooftop partySaturday · 7 pm

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-fold-in to any element.
  3. Give its parent position: relative (see the note).

Options

Add any of these attributes to the same element.

data-axisx is a horizontal crease that opens up and down, y a vertical one that opens sideways. Give both, x y, and each loop uses the next. Default x.
data-angleHow far folded it starts, in degrees. Default 85.
data-perspectiveHow close the viewer is, in px. Smaller is more dramatic. Default 700.
data-durationHow long the unfold takes. Default 1000 ms.
data-delayWait before the first entrance. Default 0 ms.
data-loopHold, fade away and unfold again.
data-holdHow long it stays before fading, when looping. Default 1400 ms.

While it unfolds, the script lays a hidden copy of the element over it to draw the second half, then removes it. The copy is positioned inside the element’s parent, so the parent should be its positioned container (position: relative).

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Fold In: the element unfolds from a crease across its middle. Both
    // halves start almost edge-on, folded away from you along the crease, and
    // open out flat like a greetings card, brightening as they face you.
    // Usage: <div data-fold-in>…</div>
    // While it unfolds, a copy of the element (aria-hidden) sits on top of it
    // to draw the second half, so the element's parent should be its
    // positioned container (the copy is placed with offsetLeft/offsetTop).
    // Optional: data-axis ("x", default, a horizontal crease that opens up
    // and down; "y" a vertical crease that opens sideways; give both, "x y",
    // and each loop uses the next), data-angle (how far folded it starts, in
    // degrees, default 85), data-perspective (px, default 700),
    // data-duration (ms, default 1000), data-delay (ms before the first
    // entrance), data-loop (hold, fade away and unfold again), data-hold (ms
    // to stay when looping, default 1400).
    var DEFAULTS = { axis: 'x', angle: 85, perspective: 700, duration: 1000, delay: 0, hold: 1400 };

    function reduced() {
      return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    }

    // A copy of the element laid exactly over it, to be the second half.
    function makeCopy(el) {
      var c = el.cloneNode(true);
      c.removeAttribute('data-fold-in');
      c.removeAttribute('id');
      c.querySelectorAll('[id]').forEach(function (n) { n.removeAttribute('id'); });
      c.setAttribute('aria-hidden', 'true');
      c.style.position = 'absolute';
      c.style.left = el.offsetLeft + 'px';
      c.style.top = el.offsetTop + 'px';
      c.style.width = el.offsetWidth + 'px';
      c.style.height = el.offsetHeight + 'px';
      c.style.margin = '0';
      c.style.boxSizing = 'border-box';
      c.style.pointerEvents = 'none';
      el.parentNode.insertBefore(c, el.nextSibling);
      return c;
    }

    function enter(el, axis, done) {
      var d = el.dataset;
      var t = +d.duration || DEFAULTS.duration;
      var a = d.angle === undefined ? DEFAULTS.angle : +d.angle || 0;
      var p = (+d.perspective || DEFAULTS.perspective) + 'px';
      var rot = axis === 'y' ? 'rotateY' : 'rotateX';
      var copy = makeCopy(el);
      // The element draws the first half (top or left), the copy the second.
      // Signs tip both outer edges away from the viewer, so the crease sits nearest.
      var first = axis === 'y' ? -a : a;
      el.style.clipPath = axis === 'y' ? 'inset(0 50% 0 0)' : 'inset(0 0 50% 0)';
      copy.style.clipPath = axis === 'y' ? 'inset(0 0 0 50%)' : 'inset(50% 0 0 0)';
      el.style.transformOrigin = copy.style.transformOrigin = '50% 50%';
      var halves = [[el, first], [copy, -first]];
      var tl = anime.createTimeline({
        onComplete: function () {
          copy.remove();
          el.style.clipPath = '';
          el.style.filter = '';
          done();
        }
      });
      halves.forEach(function (h) {
        var start = { perspective: p, opacity: 0, filter: 'brightness(0.6)' };
        start[rot] = h[1];
        anime.utils.set(h[0], start);
        var props = {
          opacity: { to: 1, duration: t * 0.2, ease: 'linear' },
          filter: { to: 'brightness(1)', duration: t * 0.72, ease: 'out(2)' }
        };
        // Open flat, just past it, and settle.
        props[rot] = [
          { to: -h[1] * 0.05, duration: t * 0.72, ease: 'out(2)' },
          { to: 0, duration: t * 0.3, ease: 'inOut(2)' }
        ];
        tl.add(h[0], props, 0);
      });
    }

    function cycle(el) {
      var d = el.dataset;
      var s = el._fdi;
      var axes = (d.axis || DEFAULTS.axis).split(/[\s,]+/);
      enter(el, axes[s.n++ % axes.length], function () {
        if (d.loop === undefined || d.loop === 'false') return;
        setTimeout(function () {
          anime.animate(el, {
            opacity: 0,
            duration: 400,
            ease: 'out(2)',
            onComplete: function () {
              // Wait until it's back on screen before the next entrance.
              setTimeout(function () {
                if (s.visible) cycle(el);
                else s.waiting = true;
              }, 500);
            }
          });
        }, +d.hold || DEFAULTS.hold);
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._fdi;
          s.visible = e.isIntersecting;
          if (!e.isIntersecting) return;
          if (!s.started) {
            s.started = true;
            setTimeout(function () { cycle(e.target); }, +e.target.dataset.delay || DEFAULTS.delay);
          } else if (s.waiting) {
            s.waiting = false;
            cycle(e.target);
          }
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-fold-in]').forEach(function (el) {
        if (el._fdi) return;
        el._fdi = { n: 0 };
        if (reduced()) return;
        anime.utils.set(el, { opacity: 0 });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div style="position: relative">
  <div class="card" data-fold-in>You're invited</div>
</div>

<!-- or a vertical crease that opens sideways: -->
<div class="card" data-fold-in data-axis="y">You're invited</div>