← SVG & icons

Envelope Open

The wax seal pops, the flap folds back and a letter slides up out of the envelope with a little spring. Loop it and the letter tucks back in, the flap closes and the seal stamps back on.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-envelope-open to an empty <span>. Size it with font-size: the envelope is 1em wide.

Options

Add any of these attributes to the same element.

data-textA short line written on the letter, e.g. Hello!.
data-colorEnvelope colour. Default #8b5cf6.
data-sealSeal colour. Default #f43f5e; none for no seal.
data-loopOpen, hold, close and repeat.
data-holdHow long it stays open, when looping. Default 2000 ms.
data-delayWait before it opens.

It opens when scrolled into view. With reduced motion the letter simply shows, already out.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Envelope Open: the wax seal pops, the flap folds open and a letter slides
    // up out of the envelope. Loop it and the letter tucks back in, the flap
    // closes and the seal returns. Starts when scrolled into view.
    // Usage: <span data-envelope-open></span>   (size it with font-size)
    // Optional: data-text (a short line written on the letter), data-color
    // (envelope colour, default #8b5cf6), data-seal (seal colour, default
    // #f43f5e, or "none"), data-loop (open, hold, close and repeat), data-hold
    // (ms it stays open when looping, default 2000), data-delay (ms before it starts).
    var DEFAULTS = { color: '#8b5cf6', seal: '#f43f5e', hold: 2000 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.env{display:inline-block;line-height:0}' +
      '.env svg{display:block;width:1em;height:1.1em;overflow:visible}' +
      '.env-flap{transform-origin:60px 62px}' +
      '.env-seal{transform-origin:60px 90px}' +
      '.env-whole{transform-origin:60px 124px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function esc(t) { return String(t).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }

    function build(el) {
      var d = el.dataset;
      var color = d.color || DEFAULTS.color;
      var seal = d.seal || DEFAULTS.seal;
      var text = d.text ? '<text x="60" y="80" text-anchor="middle" font-size="10" font-weight="700" fill="#1f2937" font-family="inherit">' + esc(d.text) + '</text>' : '';
      var lines = d.text ? [88, 96, 104] : [78, 86, 94, 102];
      var svg = document.createElementNS(NS, 'svg');
      svg.setAttribute('viewBox', '0 10 120 116');
      svg.setAttribute('aria-hidden', 'true');
      svg.innerHTML =
        '<g class="env-whole">' +
        // Back of the envelope, a shade darker.
        '<rect x="10" y="60" width="100" height="64" rx="5" fill="' + color + '"/>' +
        '<rect x="10" y="60" width="100" height="64" rx="5" fill="#000" opacity=".28"/>' +
        '<g class="env-letter"><rect x="20" y="66" width="80" height="54" rx="3" fill="#fff"/>' + text +
        lines.map(function (y, i) { return '<rect x="30" y="' + y + '" width="' + (i === lines.length - 1 ? 34 : 60) + '" height="3" rx="1.5" fill="#d4d4d8"/>'; }).join('') +
        '</g>' +
        // The front pocket, with a V cut where the letter shows.
        '<path d="M10 64 L60 98 L110 64 V119 a5 5 0 0 1 -5 5 H15 a5 5 0 0 1 -5 -5 Z" fill="' + color + '"/>' +
        '<path d="M10 122 L52 92 M110 122 L68 92" stroke="#000" stroke-opacity=".12" stroke-width="1.5"/>' +
        '<g class="env-flap"><path d="M12 61 L60 97 L108 61 Z" fill="' + color + '" stroke="' + color + '" stroke-width="4" stroke-linejoin="round"/>' +
        '<path d="M12 61 L60 97 L108 61 Z" fill="#fff" opacity=".14"/></g>' +
        (seal === 'none' ? '' : '<g class="env-seal"><circle cx="60" cy="90" r="7.5" fill="' + seal + '"/>' +
          '<path d="M60 94 C55 90.5 55 86.5 57.6 86.5 C58.8 86.5 59.6 87.4 60 88.2 C60.4 87.4 61.2 86.5 62.4 86.5 C65 86.5 65 90.5 60 94 Z" fill="#fff" opacity=".9"/></g>') +
        '</g>';
      el.appendChild(svg);
      return {
        el: el, svg: svg,
        whole: svg.querySelector('.env-whole'),
        letter: svg.querySelector('.env-letter'),
        flap: svg.querySelector('.env-flap'),
        seal: svg.querySelector('.env-seal')
      };
    }

    // Closed, the flap is on top of everything; open, it folds behind the letter.
    function flapBehind(s, behind) {
      var g = s.whole;
      if (behind) g.insertBefore(s.flap, s.letter);
      else g.insertBefore(s.flap, s.seal || null);
    }

    function showOpen(s) {
      flapBehind(s, true);
      anime.utils.set(s.flap, { scaleY: -1 });
      anime.utils.set(s.letter, { y: -50 });
      if (s.seal) anime.utils.set(s.seal, { scale: 0 });
    }

    function open(s) {
      var tl = anime.createTimeline();
      if (s.seal) tl.add(s.seal, { scale: [{ to: 1.25, duration: 140, ease: 'out(2)' }, { to: 0, duration: 200, ease: 'in(2)' }] });
      tl.add(s.flap, { scaleY: 0, duration: 260, ease: 'in(2)' })
        .call(function () { flapBehind(s, true); })
        .add(s.flap, { scaleY: -1, duration: 420, ease: 'outBack(1.6)' })
        .add(s.letter, { y: -50, duration: 760, ease: 'outBack(1.2)' }, '-=200')
        .add(s.whole, { scaleY: [1, 0.96, 1], duration: 360, ease: 'out(2)' }, '<<');
      return tl;
    }

    function close(s) {
      var tl = anime.createTimeline();
      tl.add(s.letter, { y: 0, duration: 560, ease: 'inOut(3)' })
        .add(s.flap, { scaleY: 0, duration: 300, ease: 'in(2)' }, '-=120')
        .call(function () { flapBehind(s, false); })
        .add(s.flap, { scaleY: 1, duration: 260, ease: 'out(2)' });
      if (s.seal) tl.add(s.seal, { scale: [0, 1], duration: 500, ease: 'outElastic(1, .6)' }, '-=80');
      return tl;
    }

    function play(s) {
      var d = s.el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var hold = +d.hold || DEFAULTS.hold;
      var cycle = function () {
        open(s);
        if (!loop) return;
        setTimeout(function () {
          close(s);
          setTimeout(cycle, 1900);
        }, 1800 + hold);
      };
      setTimeout(cycle, +d.delay || 200);
    }

    function setup(el) {
      el.classList.add('env');
      el._env = build(el);
      if (!el.getAttribute('aria-label') && !el.closest('[aria-hidden="true"]')) {
        el.setAttribute('role', 'img');
        el.setAttribute('aria-label', el.dataset.text ? 'A letter: ' + el.dataset.text : 'An envelope opening');
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target._env);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-envelope-open]').forEach(function (el) {
        if (el._env) return;
        setup(el);
        if (reduced()) showOpen(el._env);
        else io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<span data-envelope-open data-text="You're invited!" style="font-size: 160px"></span>