← Illustrations

Mailbox Flag

A letter flutters down like a leaf, straightens up and slips into the slot on top of a mailbox. The box gives a little jolt and its flag springs up with a wobble. Then the flag folds down, ready for the next one.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-mailbox-flag> where the scene should go. It fills the element with an SVG scene.

Options

Add any of these attributes to the same element.

data-colorColour of the mailbox. Default #7c3aed.
data-flagColour of the flag. Default #ef4444.
data-durationTime for one delivery. Default 5000 ms.
data-loop="false"Deliver one letter and leave the flag up.

It is 8:5 on a transparent background by default. It pauses when off screen. With reduced motion it shows the flag up.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Mailbox Flag: a letter flutters down from above, straightens up, slips
    // into the slot on top of a mailbox, the box gives a little jolt and its
    // flag springs up with a wobble. Then the flag folds down and the next
    // letter arrives. Drawn as SVG that fills the element, and paused when
    // off screen.
    // Usage: <div data-mailbox-flag></div>
    // It is 8:5 by default on a transparent background.
    // Optional: data-color (colour of the mailbox, default #7c3aed),
    // data-flag (colour of the flag, default #ef4444), data-duration (one
    // delivery in ms, default 5000), data-loop="false" (deliver once and
    // leave the flag up).
    var DEFAULTS = { color: '#7c3aed', flag: '#ef4444', duration: 5000 };

    var css =
      '[data-mailbox-flag]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
      '.mbf-svg{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:visible}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function clamp(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
    function inOut(v) { v = clamp(v); return v < 0.5 ? 2 * v * v : 1 - Math.pow(-2 * v + 2, 2) / 2; }
    function inQuad(v) { v = clamp(v); return v * v; }
    // A damped spring from 0 to 1 that overshoots and settles.
    function spring(v) { v = clamp(v); return 1 - Math.exp(-5.5 * v) * Math.cos(v * 13); }

    function build(el) {
      var d = el.dataset;
      var box = d.color || DEFAULTS.color;
      var flagCol = d.flag || DEFAULTS.flag;
      var dur = +d.duration || DEFAULTS.duration;
      var loop = d.loop !== 'false';
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A letter dropping into a mailbox and the flag popping up');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      el.innerHTML =
        '<svg class="mbf-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<ellipse cx="200" cy="228" rx="78" ry="9" fill="#86c98a" opacity=".5"/>' +
        '<path d="M150 228q3 -10 5 0q2 -12 5 0q3 -8 4 0Z M236 228q3 -9 5 0q2 -11 5 0Z" fill="#5cb96f"/>' +
        '<rect x="193" y="150" width="14" height="78" rx="2" fill="#a16207"/><rect x="193" y="150" width="5" height="78" fill="#fff" opacity=".15"/>' +
        // The letter sits behind the box, so it vanishes into the slot.
        '<g class="mbf-letter">' +
          '<rect x="-21" y="-14" width="42" height="28" rx="2.5" fill="#fff" stroke="#cbd5e1" stroke-width="1.2"/>' +
          '<path d="M-20 -12.5L0 2L20 -12.5" fill="none" stroke="#cbd5e1" stroke-width="1.2" stroke-linejoin="round"/>' +
          '<rect x="11" y="-10" width="7" height="8" rx="1" fill="' + box + '" opacity=".75"/>' +
          '<path d="M0 6.5C-3 4 -5.5 2.5 -5.5 0.2C-5.5 -1.5 -4 -2.6 -2.6 -2.6C-1.5 -2.6 -.6 -2 0 -1C.6 -2 1.5 -2.6 2.6 -2.6C4 -2.6 5.5 -1.5 5.5 .2C5.5 2.5 3 4 0 6.5Z" fill="' + flagCol + '"/>' +
        '</g>' +
        '<g class="mbf-box">' +
          '<path d="M148 158L148 128Q148 104 172 104L238 104Q262 104 262 128L262 158Q262 162 258 162L152 162Q148 162 148 158Z" fill="' + box + '"/>' +
          '<path d="M164 112Q170 108 182 108L232 108" stroke="#fff" stroke-opacity=".3" stroke-width="4" fill="none" stroke-linecap="round"/>' +
          '<path d="M148 150L262 150L262 158Q262 162 258 162L152 162Q148 162 148 158Z" fill="#000" opacity=".15"/>' +
          '<path d="M148 158L148 128Q148 104 160 104L160 162L152 162Q148 162 148 158Z" fill="#000" opacity=".12"/>' +
          '<rect x="152" y="128" width="5" height="12" rx="2.5" fill="#e2e8f0"/>' +
          '<rect x="184" y="101.5" width="44" height="5" rx="2.5" fill="#1e1b2e"/>' +
          '<g class="mbf-flag"><rect x="-2.5" y="-46" width="5" height="48" rx="2.5" fill="#94a3b8"/>' +
            '<path d="M-1 -46L-24 -46Q-27 -46 -27 -43L-27 -33Q-27 -30 -24 -30L-1 -30Z" fill="' + flagCol + '"/></g>' +
          '<circle cx="244" cy="134" r="4" fill="#e2e8f0"/>' +
        '</g>' +
        '<g class="mbf-pop" stroke="' + flagCol + '" stroke-width="2.5" stroke-linecap="round"><path/><path/><path/></g>' +
        '</svg>';

      var svg = el.firstChild;
      var letter = svg.querySelector('.mbf-letter');
      var boxG = svg.querySelector('.mbf-box');
      var flag = svg.querySelector('.mbf-flag');
      var pops = svg.querySelectorAll('.mbf-pop path');

      // Times within one delivery, in ms of the default 5000.
      function frame(ms) {
        var k = ms / dur * 5000;
        // 1. The letter flutters down from the upper left, swaying like a leaf.
        var fall = clamp(k / 1700);
        var fe = inOut(fall);
        var lx = 112 + (206 - 112) * fe + Math.sin(fall * Math.PI * 3) * 16 * (1 - fall);
        var ly = -30 + (68 - -30) * fe;
        var lr = Math.sin(fall * Math.PI * 3 + 0.6) * 28 * (1 - fall);
        // 2. It slips down into the slot.
        var slip = inQuad((k - 1750) / 380);
        ly += slip * 54;
        letter.setAttribute('transform', 'translate(' + lx.toFixed(1) + ' ' + ly.toFixed(1) + ') rotate(' + lr.toFixed(2) + ')');
        letter.setAttribute('opacity', k > 2200 ? 0 : 1);
        // 3. The box jolts as it lands.
        var jt = (k - 2130) / 360;
        var jolt = jt > 0 && jt < 1 ? Math.sin(jt * Math.PI * 3) * (1 - jt) : 0;
        boxG.setAttribute('transform', 'translate(205 162) scale(' + (1 + jolt * 0.025).toFixed(4) + ' ' + (1 - jolt * 0.04).toFixed(4) + ') translate(-205 -162)');
        // 4. The flag springs up, holds, then folds down for the next letter.
        var up = spring((k - 2250) / 1100);
        if (k < 2250) up = 0;
        if (loop) up *= 1 - inOut((k - 4300) / 500);
        flag.setAttribute('transform', 'translate(244 134) rotate(' + (-90 + 90 * up).toFixed(2) + ')');
        // A little burst of lines round the flag as it pops.
        var pp = clamp((k - 2350) / 450);
        for (var i = 0; i < pops.length; i++) {
          var a = (-150 + i * 40) * Math.PI / 180;
          var r0 = 6 + pp * 12, r1 = r0 + 7 * (1 - pp);
          var cx = 228, cy = 92;
          pops[i].setAttribute('d', 'M' + (cx + Math.cos(a) * r0).toFixed(1) + ' ' + (cy + Math.sin(a) * r0).toFixed(1) + 'L' + (cx + Math.cos(a) * r1).toFixed(1) + ' ' + (cy + Math.sin(a) * r1).toFixed(1));
          pops[i].setAttribute('opacity', pp > 0 && pp < 1 ? (1 - pp).toFixed(2) : 0);
        }
      }

      if (reduced()) { loop = false; frame(dur); return; }

      var t = 0, last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          t += last ? Math.min(50, now - last) : 16;
          last = now;
          if (!loop && t >= dur) { frame(dur); timer.pause(); return; }
          frame(t % dur);
        }
      });
      frame(0);
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { if (loop || t < dur) { last = 0; timer.play(); } } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-mailbox-flag]').forEach(function (el) {
        if (el._mbf) return;   // idempotent
        el._mbf = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-mailbox-flag></div>
<h2>Thanks, your message is on its way</h2>

<!-- once, after a form is sent: -->
<div data-mailbox-flag data-loop="false"></div>