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
- Paste the snippet just before
</head>. - 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-color | Colour of the tray. Default #7c3aed. |
data-speed | Speed 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.
<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><div data-empty-inbox></div>
<h2>You’re all caught up</h2>