Paper Plane Send
Press send and the label slips away while the button folds itself into a paper plane, winds back, then shoots off up into the distance. A green “Sent” button with a tick pops up in its place, then it quietly turns back into a send button.
How to use it
- Paste the snippet just before
</head>. - Add
data-paper-plane-sendto a button with a solid or gradient background. - Send the message when it fires its
sendevent.
Options
Add any of these attributes to the same element.
data-done | Label once sent. Default “Sent”. |
data-done-color | Colour of the sent state. Default #16a34a. |
data-hold | How long “Sent” stays. Default 1600 ms. |
data-autoplay | It sends itself every few seconds while in view, without firing the event. Handy for demos. |
The script adds the plane icon in front of your label. Leave some room above and to the right for the flight. With reduced motion it skips the flight and just shows “Sent”.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Paper Plane Send: press the send button and its label slips away while
// the button folds itself into a paper plane, which winds back and then
// shoots off up into the distance. A green "Sent" button with a tick pops
// up in its place, then it quietly turns back into a send button.
// Usage: <button data-paper-plane-send>Send</button>
// Optional: data-done (label once sent, default "Sent"), data-done-color
// (colour of the sent state, default #16a34a), data-hold (ms the sent state
// stays, default 1600), data-autoplay (it sends itself every few seconds
// while in view, without firing the event; handy for demos).
// Events: fires "send" on the button when pressed.
// The button needs a solid or gradient background. With reduced motion it
// skips the flight and just shows "Sent".
var DEFAULTS = { done: 'Sent', doneColor: '#16a34a', hold: 1600, every: 4800 };
var PLANE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M2 21 23 12 2 3v7l15 2-15 2z"/></svg>';
var TICK = '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" pathLength="1" d="M5 12.5l4.5 4.5L19 7.5"/></svg>';
// The button's outline as 7 points (a rectangle) and the same 7 points
// pulled into a paper plane pointing right.
var RECT = [[0, 100], [100, 100], [100, 0], [0, 0], [0, 38.9], [0, 50], [0, 61.1]];
var PLANE_PTS = [[0, 100], [100, 50], [100, 50], [0, 0], [0, 38.9], [71.4, 50], [0, 61.1]];
var css =
'.pps{position:relative;isolation:isolate;background:transparent!important;box-shadow:none!important}' +
'.pps-bg,.pps-ok{position:absolute;inset:0;z-index:-1;border-radius:inherit;pointer-events:none}' +
'.pps-ok{opacity:0}' +
'.pps-label,.pps-done{display:inline-flex;align-items:center;gap:.5em}' +
'.pps-done{position:absolute;inset:0;justify-content:center;opacity:0;pointer-events:none}' +
'.pps svg{width:1.1em;height:1.1em;flex:none}' +
'.pps-done path{stroke-dasharray:1 1;stroke-dashoffset:1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function poly(t) {
return 'polygon(' + RECT.map(function (p, i) {
var q = PLANE_PTS[i];
return (p[0] + (q[0] - p[0]) * t) + '% ' + (p[1] + (q[1] - p[1]) * t) + '%';
}).join(',') + ')';
}
function setup(el) {
var d = el.dataset;
var hold = +d.hold || DEFAULTS.hold;
var cs = getComputedStyle(el);
var bg = document.createElement('span');
bg.className = 'pps-bg';
bg.style.background = cs.background;
bg.style.boxShadow = cs.boxShadow;
var ok = document.createElement('span');
ok.className = 'pps-ok';
ok.style.background = d.doneColor || DEFAULTS.doneColor;
ok.style.boxShadow = '0 8px 22px color-mix(in srgb,' + (d.doneColor || DEFAULTS.doneColor) + ' 35%,transparent)';
el.classList.add('pps');
var label = document.createElement('span');
label.className = 'pps-label';
while (el.firstChild) label.appendChild(el.firstChild);
label.insertAdjacentHTML('afterbegin', PLANE);
var done = document.createElement('span');
done.className = 'pps-done';
done.setAttribute('aria-hidden', 'true');
done.innerHTML = TICK + '<span></span>';
done.lastChild.textContent = d.done || DEFAULTS.done;
var tick = done.querySelector('path');
el.appendChild(bg);
el.appendChild(ok);
el.appendChild(label);
el.appendChild(done);
var busy = false;
function showDone() {
label.setAttribute('aria-hidden', 'true');
done.removeAttribute('aria-hidden');
anime.animate(ok, { opacity: [0, 1], scale: [0.6, 1], duration: 520, ease: 'outBack(2.2)' });
anime.animate(done, { opacity: [0, 1], y: [10, 0], duration: 360, delay: 120, ease: 'out(3)' });
anime.animate(tick, { strokeDashoffset: [1, 0], duration: 420, delay: 260, ease: 'out(2)' });
setTimeout(reset, hold + 520);
}
function reset() {
// The original button is already back underneath; fade the sent state away.
anime.utils.set(bg, { x: 0, y: 0, rotate: 0, scaleX: 1, scale: 1, opacity: 1 });
bg.style.clipPath = bg.style.borderRadius = '';
anime.animate(done, { opacity: 0, y: -8, duration: 260, ease: 'in(2)' });
anime.animate(ok, { opacity: 0, duration: 380, delay: 120, ease: 'inOut(2)' });
anime.animate(label, {
opacity: [0, 1], y: [10, 0], duration: 380, delay: 220, ease: 'out(3)',
onComplete: function () {
label.removeAttribute('aria-hidden');
done.setAttribute('aria-hidden', 'true');
anime.utils.set(tick, { strokeDashoffset: 1 });
busy = false;
}
});
}
function send() {
if (busy) return;
busy = true;
if (reduced()) {
anime.utils.set(label, { opacity: 0 });
anime.utils.set(bg, { opacity: 0 });
anime.utils.set(ok, { opacity: 1 });
anime.utils.set(done, { opacity: 1 });
anime.utils.set(tick, { strokeDashoffset: 0 });
label.setAttribute('aria-hidden', 'true');
done.removeAttribute('aria-hidden');
setTimeout(function () {
[label, bg].forEach(function (n) { anime.utils.set(n, { opacity: 1 }); });
[ok, done].forEach(function (n) { anime.utils.set(n, { opacity: 0 }); });
label.removeAttribute('aria-hidden');
done.setAttribute('aria-hidden', 'true');
busy = false;
}, hold);
return;
}
var w = el.offsetWidth, h = el.offsetHeight;
// Square off the corners as it folds, so the plane has sharp points.
var r = Math.min(parseFloat(getComputedStyle(bg).borderTopLeftRadius) || 0, h / 2);
anime.animate(bg, { borderRadius: [r + 'px', '2px'], duration: 300, delay: 100, ease: 'out(2)' });
var fold = { t: 0 };
anime.animate(label, { opacity: 0, y: 8, duration: 180, ease: 'in(2)' });
// Fold: squeeze to a plane's proportions while the outline pulls in.
anime.animate(fold, {
t: 1, duration: 420, delay: 100, ease: 'inOut(2)',
onUpdate: function () { bg.style.clipPath = poly(fold.t); }
});
anime.animate(bg, { scaleX: Math.min(1, h * 1.2 / w), rotate: -6, duration: 420, delay: 100, ease: 'inOut(2)' });
// Wind back, then shoot off up and to the right, shrinking into the distance.
var tl = anime.createTimeline({ delay: 520, onComplete: showDone });
tl.add(bg, { x: -8, y: 4, rotate: 4, duration: 160, ease: 'out(2)' })
.add(bg, {
x: Math.min(90, w * 0.45), y: -h - 26, rotate: -22, scale: 0.4,
duration: 520, ease: 'in(2.2)'
})
.add(bg, { opacity: 0, duration: 200, ease: 'linear' }, '-=220');
}
el.addEventListener('click', function () {
if (busy) return;
el.dispatchEvent(new CustomEvent('send', { bubbles: true }));
send();
});
if (!reduced() && d.autoplay !== undefined && d.autoplay !== 'false') {
var timer = null;
var loop = function () {
if (!el.matches(':hover')) send();
timer = setTimeout(loop, DEFAULTS.every);
};
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(loop, 700);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-paper-plane-send]').forEach(function (el) {
if (el._pps) return;
el._pps = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><button id="send" data-paper-plane-send>Send</button>
<script>
document.getElementById("send").addEventListener("send", () => {
fetch("/messages", { method: "POST" });
});
</script>