← Forms

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

  1. Paste the snippet just before </head>.
  2. Add data-form-success-fold to your <form>.
  3. Give the form an action to 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-titleHeading of the thank-you message. Default Thank you!.
data-messageText under the heading. Default Your message is on its way. We’ll be in touch soon.
data-againText of the button that brings the form back. Default Send another. none hides it.
data-colorColour of the tick and button. Default #6a0dad.
data-autoplayIt 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.

1 · Snippet
<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>
2 · Your HTML
<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>