Form Success Fold
Send the form and it folds away like a letter: the bottom third folds up, the top third folds down over it, and the folded packet flies off while the card shrinks around a thank-you message whose tick draws itself. “Send another” unfolds a fresh, empty form.
How to use it
- Paste the snippet just before
</head>. - Add
data-form-success-foldto your<form>. - Give the form an
actionto send it in the background: it folds once the server says yes. Without one it just folds.
Options
Add any of these attributes to the same element.
data-title | Heading of the thank-you message. Default Thank you!. |
data-message | Text under the heading. Default Your message is on its way. We’ll be in touch soon. |
data-again | Text of the button that brings the form back. Default Send another. none hides it. |
data-color | Colour of the tick and button. Default #6a0dad. |
data-autoplay | It fills the fields from their data-sample values, sends, folds and unfolds by itself while in view. It stops as soon as you use the form. |
The browser’s own validation runs first, and an unfinished form shakes instead of folding. The thank-you message is a status region, so it’s read out, and focus moves to it. Listen for the sent event (e.detail.response is the server’s response) or failed.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Form Success Fold: send the form and it folds away like a letter. The
// bottom third folds up, the top third folds down over it, and the folded
// packet flies off while the card shrinks around a thank-you message
// whose tick draws itself. "Send another" unfolds a fresh, empty form.
// Usage: <form data-form-success-fold>…fields…<button>Send</button></form>
// If the form has an action, it's sent in the background (fetch) and
// folds once the server says yes; without one it just folds.
// Optional: data-title (default "Thank you!"), data-message (default
// "Your message is on its way. We'll be in touch soon."), data-again
// (text of the button that brings the form back, default "Send another";
// "none" hides it), data-color (tick and button colour, default #6a0dad),
// data-autoplay (it fills the fields from their data-sample values, sends,
// folds and unfolds by itself while in view; it stops as soon as you use
// the form).
// Listen for the "sent" event (e.detail.response is the fetch response,
// if any) or "failed" if the server said no.
var DEFAULTS = { title: 'Thank you!', message: 'Your message is on its way. We’ll be in touch soon.', again: 'Send another', color: '#6a0dad' };
var CHECK = '<svg viewBox="0 0 52 52" aria-hidden="true"><circle cx="26" cy="26" r="23" fill="none" stroke="currentColor" stroke-width="3" stroke-dasharray="145" stroke-dashoffset="145"/>' +
'<path d="M15 27l7.5 7.5L37.5 19" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="34" stroke-dashoffset="34"/></svg>';
var css =
'.fsf{position:relative}' +
'.fsf.fsf-folded{background:transparent!important;box-shadow:none!important;border-color:transparent!important}' +
'.fsf-folded>:not(.fsf-layer):not(.fsf-msg){display:none!important}' +
'.fsf-layer{position:absolute;left:0;top:0;width:100%;perspective:900px;pointer-events:none}' +
'.fsf-strip{position:absolute;left:0;transform-style:preserve-3d}' +
'.fsf-face,.fsf-back{position:absolute;inset:0;overflow:hidden;-webkit-backface-visibility:hidden;backface-visibility:hidden}' +
'.fsf-back{transform:rotateX(180deg)}' +
'.fsf-paper{position:absolute;left:0;box-sizing:border-box;margin:0}' +
'.fsf-shade{position:absolute;inset:0;background:#18181b;opacity:0}' +
'.fsf-msg{display:flex;flex-direction:column;align-items:center;gap:.45em;padding:.6em 0;text-align:center}' +
'.fsf-msg svg{width:3.2em;height:3.2em;margin-bottom:.2em;color:var(--fsf-color)}' +
'.fsf-msg strong{font-size:1.2em}' +
'.fsf-msg p{margin:0;color:#71717a;max-width:22em}' +
'.fsf-again{margin:.5em 0 0;padding:.55em 1.1em;border:0;border-radius:2em;background:transparent;color:var(--fsf-color);font:inherit;font-weight:600;' +
'box-shadow:inset 0 0 0 1.5px currentColor;cursor:pointer;touch-action:manipulation}' +
'.fsf-again:focus-visible{outline:2px solid var(--fsf-color);outline-offset:2px}' +
'.fsf-press{transform:scale(.95);filter:brightness(.92)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function on(v) { return v !== undefined && v !== 'false'; }
function setup(el) {
var d = el.dataset;
var mock = el.tagName !== 'FORM' || !!el.closest('[aria-hidden="true"]');
var color = d.color || DEFAULTS.color;
el.classList.add('fsf');
el.style.setProperty('--fsf-color', color);
var layer = null, msg = null, busy = false, folded = false;
// A paper copy of the form, cut into three strips that can fold.
function buildLayer(h) {
var w = el.offsetWidth, cs = getComputedStyle(el), r = cs.borderTopLeftRadius;
layer = document.createElement('div');
layer.className = 'fsf-layer';
layer.setAttribute('aria-hidden', 'true');
layer.style.height = h + 'px';
var originals = el.querySelectorAll('input, textarea, select');
var third = h / 3, strips = [];
for (var i = 0; i < 3; i++) {
var strip = document.createElement('div');
strip.className = 'fsf-strip';
strip.style.top = (i * third) + 'px';
strip.style.width = w + 'px';
strip.style.height = third + 'px';
strip.style.transformOrigin = i === 0 ? '50% 100%' : i === 2 ? '50% 0' : '50% 50%';
var face = document.createElement('div'), back = document.createElement('div');
face.className = 'fsf-face';
back.className = 'fsf-back';
var radius = i === 0 ? r + ' ' + r + ' 0 0' : i === 2 ? '0 0 ' + r + ' ' + r : '0';
face.style.borderRadius = back.style.borderRadius = radius;
back.style.borderRadius = i === 0 ? '0 0 ' + r + ' ' + r : i === 2 ? r + ' ' + r + ' 0 0' : '0';
back.style.background = cs.backgroundColor === 'rgba(0, 0, 0, 0)' ? '#f4f4f7' : cs.backgroundColor;
back.style.backgroundImage = 'linear-gradient(rgba(0,0,0,.03),rgba(0,0,0,.06))';
if (i === 1) face.style.boxShadow = back.style.boxShadow = cs.boxShadow;
var paper = document.createElement('div');
paper.className = 'fsf-paper ' + el.className.replace(/(^|\s)fsf[\w-]*/g, ' ');
paper.style.top = (-i * third) + 'px';
paper.style.width = w + 'px';
paper.style.height = h + 'px';
paper.style.padding = cs.padding;
paper.style.background = cs.background;
paper.style.borderRadius = cs.borderRadius;
Array.prototype.forEach.call(el.childNodes, function (c) { if (c !== layer && c !== msg) paper.appendChild(c.cloneNode(true)); });
var copies = paper.querySelectorAll('input, textarea, select');
Array.prototype.forEach.call(copies, function (c, k) {
if (originals[k]) c.value = originals[k].value;
c.removeAttribute('name');
c.removeAttribute('id');
c.disabled = true;
c.tabIndex = -1;
});
Array.prototype.forEach.call(paper.querySelectorAll('[id]'), function (c) { c.removeAttribute('id'); });
face.appendChild(paper);
var shade = document.createElement('span');
shade.className = 'fsf-shade';
face.appendChild(shade);
strip.appendChild(face);
strip.appendChild(back);
strips.push({ el: strip, shade: shade });
}
// Middle first, so the outer strips paint over it as they fold.
layer.appendChild(strips[1].el);
layer.appendChild(strips[2].el);
layer.appendChild(strips[0].el);
el.appendChild(layer);
layer.style.transformOrigin = '50% ' + (h / 2) + 'px';
return strips;
}
function buildMsg() {
// The message takes over the card's own look, so it fills the same box.
var cs = getComputedStyle(el);
msg = document.createElement('div');
msg.className = 'fsf-msg';
msg.style.background = cs.background;
msg.style.boxShadow = cs.boxShadow;
msg.style.borderRadius = cs.borderRadius;
msg.style.margin = '-' + cs.paddingTop + ' -' + cs.paddingRight + ' -' + cs.paddingBottom + ' -' + cs.paddingLeft;
msg.style.padding = 'calc(' + cs.paddingTop + ' + .6em) ' + cs.paddingRight + ' calc(' + cs.paddingBottom + ' + .6em) ' + cs.paddingLeft;
msg.innerHTML = CHECK + '<strong></strong><p></p>';
msg.querySelector('strong').textContent = d.title || DEFAULTS.title;
msg.querySelector('p').textContent = d.message || DEFAULTS.message;
var again = d.again === 'none' ? null : document.createElement(mock ? 'span' : 'button');
if (again) {
again.className = 'fsf-again';
again.textContent = d.again || DEFAULTS.again;
msg.appendChild(again);
if (!mock) {
again.type = 'button';
again.addEventListener('click', function () { stop(); unfold(true); });
}
}
if (!mock) { msg.setAttribute('role', 'status'); msg.tabIndex = -1; }
return msg;
}
var saved = '';
function fold(user) {
if (folded || busy) return;
folded = busy = true;
var h = el.offsetHeight;
saved = el.style.cssText;
if (reduced()) {
buildMsg();
el.classList.add('fsf-folded');
el.appendChild(msg);
msg.querySelector('circle').style.strokeDashoffset = 0;
msg.querySelector('path').style.strokeDashoffset = 0;
busy = false;
if (user) msg.focus({ preventScroll: true });
return;
}
var strips = buildLayer(h);
buildMsg();
el.classList.add('fsf-folded');
el.style.boxSizing = 'border-box';
el.appendChild(msg);
el.style.height = 'auto';
var h2 = el.offsetHeight;
el.style.height = h + 'px';
anime.utils.set(msg, { opacity: 0 });
var bottom = strips[2], top = strips[0];
anime.animate(bottom.el, { rotateX: [0, 180], duration: 560, ease: 'inOut(3)' });
anime.animate(bottom.shade, { opacity: [0, 0.28, 0], duration: 560, ease: 'linear' });
anime.animate(top.el, { rotateX: [0, -180], duration: 560, delay: 420, ease: 'inOut(3)' });
anime.animate(top.shade, { opacity: [0, 0.28, 0], duration: 560, delay: 420, ease: 'linear' });
anime.animate(layer, { y: [0, -h * 0.35], scale: [1, 0.45], rotate: [0, -6], opacity: [1, 0], duration: 520, delay: 1050, ease: 'in(2)' });
anime.animate(el, { height: [h, h2], duration: 520, delay: 1150, ease: 'inOut(3)' });
anime.animate(msg, { opacity: [0, 1], scale: [0.92, 1], duration: 480, delay: 1250, ease: 'out(3)' });
anime.animate(msg.querySelector('svg'), { scale: [0.5, 1], duration: 700, delay: 1350, ease: 'outElastic(1, .6)' });
anime.animate(msg.querySelector('circle'), { strokeDashoffset: [145, 0], duration: 560, delay: 1400, ease: 'out(3)' });
anime.animate(msg.querySelector('path'), {
strokeDashoffset: [34, 0], duration: 360, delay: 1800, ease: 'out(2)',
onComplete: function () {
el.style.height = '';
layer.remove();
layer = null;
busy = false;
if (user) msg.focus({ preventScroll: true });
}
});
}
function clear() {
if (el.tagName === 'FORM') el.reset();
Array.prototype.forEach.call(el.querySelectorAll('[data-field]'), function (f) { f.textContent = ''; });
}
function unfold(user) {
if (!folded || busy) return;
busy = true;
clear();
var h2 = el.offsetHeight;
if (reduced()) {
msg.remove();
msg = null;
el.classList.remove('fsf-folded');
el.style.cssText = saved;
folded = busy = false;
return;
}
// Measure the empty form, then fold a copy of it and play it all backwards.
msg.style.display = 'none';
el.classList.remove('fsf-folded');
el.style.cssText = saved;
var h = el.offsetHeight;
var strips = buildLayer(h);
el.classList.add('fsf-folded');
el.style.boxSizing = 'border-box';
msg.style.display = '';
el.style.height = h2 + 'px';
anime.utils.set(strips[2].el, { rotateX: 180 });
anime.utils.set(strips[0].el, { rotateX: -180 });
anime.utils.set(layer, { y: -h * 0.35, scale: 0.45, rotate: -6, opacity: 0 });
anime.animate(msg, { opacity: 0, scale: 0.94, duration: 280, ease: 'in(2)', onComplete: function () { msg.style.display = 'none'; } });
anime.animate(el, { height: [h2, h], duration: 480, delay: 200, ease: 'inOut(3)' });
anime.animate(layer, { y: 0, scale: 1, rotate: 0, opacity: 1, duration: 520, delay: 260, ease: 'out(2)' });
anime.animate(strips[0].el, { rotateX: 0, duration: 520, delay: 760, ease: 'inOut(3)' });
anime.animate(strips[2].el, {
rotateX: 0, duration: 520, delay: 1100, ease: 'inOut(3)',
onComplete: function () {
msg.remove();
msg = null;
layer.remove();
layer = null;
el.classList.remove('fsf-folded');
el.style.cssText = saved;
folded = busy = false;
if (user) { var f = el.querySelector('input, textarea, select'); if (f) f.focus({ preventScroll: true }); }
}
});
}
function shake() {
if (!reduced()) anime.animate(el, { x: [0, -8, 8, -5, 5, 0], duration: 420, ease: 'out(2)' });
}
if (!mock) {
el.addEventListener('submit', function (e) {
e.preventDefault();
if (folded || busy) return;
if (!el.checkValidity()) { el.reportValidity(); shake(); return; }
var action = el.getAttribute('action');
if (!action || !window.fetch) { fold(true); el.dispatchEvent(new CustomEvent('sent', { bubbles: true, detail: { response: null } })); return; }
var method = (el.getAttribute('method') || 'get').toUpperCase(), data = new FormData(el), url = el.action;
var opts = { method: method };
if (method === 'GET') url += (url.indexOf('?') < 0 ? '?' : '&') + new URLSearchParams(data).toString();
else opts.body = data;
el.setAttribute('aria-busy', 'true');
fetch(url, opts).then(function (res) {
el.removeAttribute('aria-busy');
if (!res.ok) throw res;
fold(true);
el.dispatchEvent(new CustomEvent('sent', { bubbles: true, detail: { response: res } }));
}).catch(function (err) {
el.removeAttribute('aria-busy');
shake();
el.dispatchEvent(new CustomEvent('failed', { bubbles: true, detail: { response: err && err.status ? err : null } }));
});
});
el.addEventListener('pointerdown', function () { stop(); });
el.addEventListener('keydown', function (e) { if (e.isTrusted) stop(); });
}
// Autoplay: types each field's data-sample, presses send, folds, and unfolds.
var stopped = false, timer = null;
function stop() { stopped = true; clearTimeout(timer); }
function later(fn, ms) { timer = setTimeout(function () { if (!stopped) fn(); }, ms); }
function type(f, text, i, then) {
var v = text.slice(0, i);
if (f.matches('input, textarea')) f.value = v; else f.textContent = v;
if (i >= text.length) { later(then, 200); return; }
later(function () { type(f, text, i + 1, then); }, 40 + Math.random() * 50);
}
function fill(list, k, then) {
if (k >= list.length) { then(); return; }
list[k].classList.add('fsf-typing');
type(list[k], list[k].getAttribute('data-sample'), 1, function () { list[k].classList.remove('fsf-typing'); fill(list, k + 1, then); });
}
function run() {
if (folded) { later(function () { unfold(false); later(run, 2000); }, 2400); return; }
fill(Array.prototype.slice.call(el.querySelectorAll('[data-sample]')), 0, function () {
var btn = el.querySelector('[data-submit], button[type="submit"], button:not([type])');
if (btn) btn.classList.add('fsf-press');
later(function () {
if (btn) btn.classList.remove('fsf-press');
fold(false);
later(run, 2000);
}, 200);
});
}
if (on(d.autoplay) && !reduced()) {
new IntersectionObserver(function (entries) {
if (stopped) return;
clearTimeout(timer);
if (entries[0].isIntersecting && !busy) later(run, 700);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-form-success-fold]').forEach(function (el) {
if (el._fsf) return;
el._fsf = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><form data-form-success-fold action="/contact" method="post">
<input name="name" placeholder="Your name" required>
<input type="email" name="email" placeholder="Email" required>
<textarea name="message" placeholder="Message" required></textarea>
<button type="submit">Send message</button>
</form>