← Illustrations

Empty Inbox

A paper plane loops lazy figures of eight over an empty in-tray, rolling over at each turn and trailing a dotted line, with nothing to deliver. For an inbox with nothing in it.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-empty-inbox> 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 tray. Default #7c3aed.
data-speedSpeed multiplier. Default 1.
data-trail="false"No dotted trail behind the plane.

It is 8:5 on a transparent background by default. It pauses when off screen. With reduced motion the plane stays still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Empty Inbox: a paper plane loops lazy figures of eight over an empty
    // in-tray, trailing a dotted line, with nothing to deliver. For empty
    // states. Drawn as SVG that fills the element, and paused when off
    // screen.
    // Usage: <div data-empty-inbox></div>
    // It is 8:5 by default on a transparent background.
    // Optional: data-color (tray colour, default #7c3aed), data-speed
    // (default 1), data-trail="false" (no dotted trail).
    var DEFAULTS = { color: '#7c3aed', speed: 1 };
    var TRAIL = 46;

    var css =
      '[data-empty-inbox]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
      '.eib-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; }

    // Where the plane is at phase a (radians): a sideways figure of eight
    // over the tray, a little higher on the way back.
    function at(a) {
      return { x: 200 + 128 * Math.sin(a), y: 92 + 34 * Math.sin(2 * a) - 10 * Math.cos(a) };
    }

    function build(el) {
      var d = el.dataset;
      var color = d.color || DEFAULTS.color;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var trail = d.trail !== 'false';
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'An empty inbox with a paper plane looping over it');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      el.innerHTML =
        '<svg class="eib-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<ellipse class="eib-shadow" cx="200" cy="236" rx="100" ry="6" fill="#1e1b2e" opacity=".08"/>' +
        // The tray: floor, back wall, and a front with the classic inbox notch.
        '<path d="M124 168H276L304 200H96Z" fill="' + color + '" opacity=".18"/>' +
        '<path d="M124 168H276" stroke="' + color + '" stroke-width="3" stroke-linecap="round" opacity=".45"/>' +
        '<path d="M96 200H158Q162 214 176 214H224Q238 214 242 200H304L296 232Q295 236 290 236H110Q105 236 104 232Z" fill="' + color + '"/>' +
        '<path d="M124 168L96 200M276 168L304 200" stroke="' + color + '" stroke-width="3" stroke-linecap="round" opacity=".6"/>' +
        '<path d="M164 224H236" stroke="#fff" stroke-width="3" stroke-linecap="round" opacity=".35"/>' +
        '<polyline class="eib-trail" fill="none" stroke="' + color + '" stroke-width="2" stroke-linecap="round" stroke-dasharray="1 7" opacity=".55"/>' +
        // The plane, nose pointing along +x.
        '<g class="eib-plane">' +
          '<path d="M18 0L-14 -12L-6 0Z" fill="#fff" stroke="#1e1b2e" stroke-width="1.6" stroke-linejoin="round"/>' +
          '<path d="M18 0L-6 0L-10 9Z" fill="#ddd6fe" stroke="#1e1b2e" stroke-width="1.6" stroke-linejoin="round"/>' +
          '<path d="M18 0L-6 0" stroke="#1e1b2e" stroke-width="1.2"/>' +
        '</g>' +
        '</svg>';

      var svg = el.firstChild;
      var plane = svg.querySelector('.eib-plane');
      var trailEl = svg.querySelector('.eib-trail');
      var shadow = svg.querySelector('.eib-shadow');
      if (!trail) trailEl.style.display = 'none';

      function draw(a) {
        var p = at(a), q = at(a + 0.01);
        var ang = Math.atan2(q.y - p.y, q.x - p.x) * 180 / Math.PI;
        // Roll over as it turns, so it is never flying upside down.
        var roll = Math.max(-1, Math.min(1, Math.cos(ang * Math.PI / 180) * 3));
        plane.setAttribute('transform', 'translate(' + p.x.toFixed(2) + ' ' + p.y.toFixed(2) + ') rotate(' + ang.toFixed(2) + ') scale(1.15 ' + (1.15 * roll).toFixed(3) + ')');
        if (trail) {
          var pts = [];
          for (var i = TRAIL; i > 0; i--) {
            var r = at(a - i * 0.045);
            pts.push(r.x.toFixed(1) + ',' + r.y.toFixed(1));
          }
          pts.push((p.x - Math.cos(ang * Math.PI / 180) * 12).toFixed(1) + ',' + (p.y - Math.sin(ang * Math.PI / 180) * 12).toFixed(1));
          trailEl.setAttribute('points', pts.join(' '));
        }
        // The tray's shadow widens a touch as the plane passes over.
        shadow.setAttribute('rx', (100 + 8 * Math.cos(a) * Math.cos(a)).toFixed(1));
      }

      if (reduced()) {
        draw(0.7);
        return;
      }

      var a = 0.3, last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) : 16;
          last = now;
          // Slower through the top of each loop, quicker on the straights.
          var p = at(a), q = at(a + 0.01);
          var climb = (p.y - q.y) * 100 / 128;
          a += dt * speed * 0.0011 * (1 - climb * 0.25);
          draw(a);
        }
      });
      draw(a);
      // Pause while off screen to save battery.
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-empty-inbox></div>
<h2>You&rsquo;re all caught up</h2>