← Forms

Multi-step Form

A long form split into steps. Next slides the current step out to the left while the next one slides in from the right, the form’s height easing to fit, and a progress bar along the top fills as you go. Each step is checked before you can move on, and gives a little shake if something’s missing. Send the last step and a thank-you slides in.

About you
Your team
Your goal

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-multi-step-form to a <form> and put each step in its own <fieldset> with a <legend>: the legend names the step.
  3. The header, progress bar and Back and Next buttons are added for you. Style the fields as you like.

Options

Add any of these attributes to the same element.

data-successText for a thank-you panel that slides in when the last step is sent, instead of the form submitting. Listen for the complete event to send the data yourself. Without it the form submits as normal.
data-nextText of the Next button. Default Next.
data-backText of the Back button. Default Back.
data-submitText of the button on the last step. Default Submit.
data-colorColour of the progress bar and buttons. Default #6a0dad.
data-durationHow long each slide takes. Default 480 ms.
data-autoplayIt fills each step from the fields’ data-sample values and moves on by itself while in view, then starts over. It stops as soon as you use the form.

Fields are checked with the browser’s own validation (required, type="email" and so on). Enter moves to the next step, focus follows the slide, and the step count is read out politely. Listen for the step event: e.detail.step is the new step.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Multi-step Form: splits a long form into steps. Next slides the current
    // step out to the left while the next one slides in from the right (Back
    // does the opposite), the form's height eases to fit, and a progress bar
    // along the top fills as you go. Each step is checked before you can move
    // on; if something's missing the step gives a little shake.
    // Usage: <form data-multi-step-form><fieldset><legend>About you</legend>…</fieldset><fieldset>…</fieldset></form>
    // Each <fieldset> (or element with data-step) is one step; its <legend>
    // (or data-title) names it in the header.
    // Optional: data-success (text for a thank-you panel that slides in when
    // the last step is sent, instead of the form submitting; listen for the
    // "complete" event to send the data yourself), data-next (default
    // "Next"), data-back (default "Back"), data-submit (last button's text,
    // default "Submit"), data-color (default #6a0dad), data-duration (slide
    // time in ms, default 480), data-autoplay (it fills each step from the
    // fields' data-sample values and moves on by itself while in view, then
    // starts over; it stops as soon as you use the form).
    // Listen for the "step" event: e.detail.step is the new step (from 1).
    var DEFAULTS = { next: 'Next', back: 'Back', submit: 'Submit', color: '#6a0dad', duration: 480 };

    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 =
      '.mstf-head{display:flex;justify-content:space-between;align-items:center;gap:1em;margin-bottom:.55em}' +
      '.mstf-title{position:relative;flex:1;min-width:0;height:1.4em;overflow:hidden;font-weight:700;color:#18181b;text-align:left}' +
      '.mstf-title span{position:absolute;left:0;top:0;right:0;line-height:1.4em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
      '.mstf-count{flex:none;font-size:.82em;color:#71717a;font-variant-numeric:tabular-nums}' +
      '.mstf-bar{height:.36em;margin-bottom:1.1em;border-radius:1em;background:#ececf0;overflow:hidden}' +
      '.mstf-bar i{display:block;height:100%;width:0;border-radius:inherit;background:var(--mstf-color)}' +
      '.mstf-view{position:relative;margin:-.35em;padding:.35em;overflow:hidden}' +
      '.mstf-step{box-sizing:border-box;min-width:0;margin:0;padding:0;border:0}' +
      '.mstf .mstf-hide{display:none}' +
      '.mstf-nav{display:flex;justify-content:space-between;gap:.6em;margin-top:1.1em}' +
      '.mstf-btn{display:inline-flex;align-items:center;justify-content:center;min-width:5.5em;box-sizing:border-box;margin:0;padding:.7em 1.3em;border:0;' +
      'border-radius:2em;background:var(--mstf-color);color:#fff;font:inherit;font-weight:600;line-height:1.2;cursor:pointer;transition:opacity .25s,transform .15s;touch-action:manipulation}' +
      '.mstf-btn:active,.mstf-press{transform:scale(.95)}' +
      '.mstf-btn:focus-visible{outline:2px solid var(--mstf-color);outline-offset:2px}' +
      '.mstf-back{background:transparent;color:#3f3f46;box-shadow:inset 0 0 0 1.5px #dcdce1}' +
      '.mstf-gone{opacity:0;pointer-events:none}' +
      '.mstf-done{display:flex;flex-direction:column;align-items:center;gap:.6em;padding:1.2em 0;text-align:center;font-weight:600;color:#18181b}' +
      '.mstf-done svg{width:3.2em;height:3.2em;color:var(--mstf-color)}' +
      '.mstf-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
    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.closest('[aria-hidden="true"]');
      var duration = parseFloat(d.duration) || DEFAULTS.duration;
      var steps = Array.prototype.filter.call(el.children, function (c) { return c.matches('fieldset, [data-step]'); });
      if (!steps.length) return;
      var n = steps.length;
      el.classList.add('mstf');
      el.style.setProperty('--mstf-color', d.color || DEFAULTS.color);
      if (!mock) el.noValidate = true;

      var titles = steps.map(function (s, i) {
        s.classList.add('mstf-step');
        var lg = s.querySelector('legend');
        if (lg) lg.classList.add('mstf-sr');
        return s.getAttribute('data-title') || (lg ? lg.textContent.trim() : 'Step ' + (i + 1));
      });

      var head = document.createElement('div');
      head.className = 'mstf-head';
      head.innerHTML = '<div class="mstf-title" aria-hidden="true"></div><span class="mstf-count"></span>';
      var titleBox = head.firstChild, count = head.lastChild;
      var bar = document.createElement('div');
      bar.className = 'mstf-bar';
      bar.innerHTML = '<i></i>';
      var fillBar = bar.firstChild;
      var view = document.createElement('div');
      view.className = 'mstf-view';
      el.insertBefore(head, steps[0]);
      el.insertBefore(bar, steps[0]);
      el.insertBefore(view, steps[0]);
      steps.forEach(function (s) { view.appendChild(s); });
      var done = null;
      if (d.success) {
        done = document.createElement('div');
        done.className = 'mstf-done mstf-hide';
        done.innerHTML = CHECK + '<span></span>';
        done.lastChild.textContent = d.success;
        if (!mock) { done.setAttribute('role', 'status'); done.tabIndex = -1; }
        view.appendChild(done);
      }
      var nav = document.createElement('div');
      nav.className = 'mstf-nav';
      var tag = mock ? 'span' : 'button';
      var back = document.createElement(tag), next = document.createElement(tag);
      back.className = 'mstf-btn mstf-back';
      next.className = 'mstf-btn';
      back.textContent = d.back || DEFAULTS.back;
      nav.appendChild(back);
      nav.appendChild(next);
      el.appendChild(nav);
      if (!mock) {
        back.type = 'button';
        next.type = 'button';
        bar.setAttribute('role', 'progressbar');
        bar.setAttribute('aria-valuemin', 0);
        bar.setAttribute('aria-valuemax', n);
        count.setAttribute('aria-live', 'polite');
      }

      var current = 0, finished = false;
      function panel(i) { return i < n ? steps[i] : done; }
      function setTitle(text, dir) {
        var span = document.createElement('span');
        span.textContent = text;
        var old = titleBox.lastChild;
        titleBox.appendChild(span);
        if (!old) return;
        if (!dir || reduced()) { titleBox.textContent = ''; titleBox.appendChild(span); return; }
        anime.animate(old, { x: ['0%', (-dir * 40) + '%'], opacity: 0, duration: duration * 0.7, ease: 'out(3)', onComplete: function () { old.remove(); } });
        anime.animate(span, { x: [(dir * 40) + '%', '0%'], opacity: [0, 1], duration: duration, ease: 'out(3)' });
      }
      function chrome(dir) {
        var last = current === n - 1;
        setTitle(finished ? 'All done' : titles[current], dir);
        count.textContent = finished ? n + ' of ' + n : 'Step ' + (current + 1) + ' of ' + n;
        var p = finished ? 1 : current / n + 0.5 / n;
        if (dir && !reduced()) anime.animate(fillBar, { width: (p * 100) + '%', duration: duration + 120, ease: 'inOut(3)' });
        else fillBar.style.width = (p * 100) + '%';
        if (!mock) bar.setAttribute('aria-valuenow', finished ? n : current + 1);
        back.classList.toggle('mstf-gone', current === 0 || finished);
        next.classList.toggle('mstf-gone', finished);
        next.textContent = last ? (d.submit || DEFAULTS.submit) : (d.next || DEFAULTS.next);
        if (!mock) {
          back.disabled = current === 0 || finished;
          next.disabled = finished;
        }
      }
      steps.forEach(function (s, i) { if (i) s.classList.add('mstf-hide'); });
      chrome(0);

      var moving = false;
      function slide(from, to, dir, after) {
        var a = panel(from), b = panel(to);
        if (reduced()) {
          a.classList.add('mstf-hide');
          b.classList.remove('mstf-hide');
          if (after) after();
          return;
        }
        moving = true;
        var h0 = view.offsetHeight;
        b.classList.remove('mstf-hide');
        a.style.position = 'absolute';
        a.style.left = a.style.right = a.style.top = '.35em';
        var h1 = view.offsetHeight;
        anime.animate(a, { x: ['0%', (-dir * 110) + '%'], opacity: [1, 0], duration: duration, ease: 'inOut(3)' });
        anime.animate(b, { x: [(dir * 110) + '%', '0%'], opacity: [0, 1], duration: duration, ease: 'inOut(3)' });
        anime.animate(view, {
          height: [h0, h1], duration: duration, ease: 'inOut(3)',
          onComplete: function () {
            a.classList.add('mstf-hide');
            a.style.position = a.style.left = a.style.right = a.style.top = a.style.transform = a.style.opacity = '';
            view.style.height = '';
            moving = false;
            if (after) after();
          }
        });
      }

      function fields(step) { return Array.prototype.slice.call(step.querySelectorAll('input, select, textarea')); }
      function valid(step) {
        var bad = fields(step).filter(function (f) { return !f.checkValidity(); })[0];
        if (!bad) return true;
        if (!reduced()) anime.animate(step, { x: [0, -8, 8, -5, 5, 0], duration: 420, ease: 'out(2)' });
        bad.reportValidity();
        return false;
      }
      function go(to, user) {
        if (moving || to < 0 || to > n - 1 || to === current) return;
        if (to > current && user && !valid(steps[current])) return;
        var dir = to > current ? 1 : -1, from = current;
        current = to;
        chrome(dir);
        slide(from, to, dir, function () {
          if (user) { var f = fields(steps[current])[0]; if (f) f.focus({ preventScroll: true }); }
        });
        if (!mock) el.dispatchEvent(new CustomEvent('step', { bubbles: true, detail: { step: current + 1 } }));
      }
      function finish(user) {
        if (moving) return;
        if (user && !valid(steps[current])) return;
        if (!done) { el.requestSubmit ? el.requestSubmit() : el.submit(); return; }
        finished = true;
        chrome(1);
        slide(current, n, 1, function () { if (user) done.focus({ preventScroll: true }); });
        if (!reduced()) {
          anime.animate(done.querySelector('circle'), { strokeDashoffset: [145, 0], duration: 600, delay: duration * 0.6, ease: 'out(3)' });
          anime.animate(done.querySelector('path'), { strokeDashoffset: [34, 0], duration: 380, delay: duration * 0.6 + 420, ease: 'out(2)' });
          anime.animate(done.querySelector('svg'), { scale: [0.6, 1], duration: 700, delay: duration * 0.6, ease: 'outElastic(1, .6)' });
        } else {
          done.querySelector('circle').style.strokeDashoffset = 0;
          done.querySelector('path').style.strokeDashoffset = 0;
        }
        if (!mock) el.dispatchEvent(new CustomEvent('complete', { bubbles: true, detail: { form: el } }));
      }
      function restart() {
        // Clears the fields and slides back to the first step.
        steps.forEach(function (s) {
          fields(s).forEach(function (f) { f.value = ''; });
          Array.prototype.forEach.call(s.querySelectorAll('[data-field]'), function (f) { f.textContent = ''; });
        });
        var from = finished ? n : current;
        finished = false;
        current = 0;
        chrome(-1);
        slide(from, 0, -1);
      }

      if (!mock) {
        next.addEventListener('click', function () { stop(); if (current === n - 1) finish(true); else go(current + 1, true); });
        back.addEventListener('click', function () { stop(); go(current - 1, true); });
        el.addEventListener('submit', function (e) {
          // Enter in a field moves to the next step; on the last one it submits.
          if (current < n - 1) { e.preventDefault(); go(current + 1, true); return; }
          if (done) { e.preventDefault(); if (!finished) finish(true); }
        });
        el.addEventListener('pointerdown', stop);
        el.addEventListener('keydown', function (e) { if (e.isTrusted) stop(); });
      }

      // Autoplay: types each field's data-sample, presses Next, and starts over.
      var stopped = false, timer = null;
      function stop() { stopped = true; clearTimeout(timer); next.classList.remove('mstf-press'); }
      function later(fn, ms) { timer = setTimeout(function () { if (!stopped) fn(); }, ms); }
      function targets(step) {
        return Array.prototype.slice.call(step.querySelectorAll('[data-sample]')).filter(function (f) { return f.matches('input, textarea, [data-field]'); });
      }
      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, 250); return; }
        later(function () { type(f, text, i + 1, then); }, 45 + Math.random() * 55);
      }
      function fillStep(list, k, then) {
        if (k >= list.length) { then(); return; }
        var f = list[k];
        f.classList.add('mstf-typing');
        type(f, f.getAttribute('data-sample'), 1, function () { f.classList.remove('mstf-typing'); fillStep(list, k + 1, then); });
      }
      function press(then) {
        next.classList.add('mstf-press');
        later(function () { next.classList.remove('mstf-press'); then(); }, 180);
      }
      function run() {
        if (finished || (!done && current === n - 1 && targets(steps[current]).every(function (f) { return f.value || f.textContent; }))) {
          later(function () { restart(); later(run, duration + 700); }, 1800);
          return;
        }
        fillStep(targets(steps[current]), 0, function () {
          later(function () {
            press(function () {
              if (current === n - 1) { if (done) finish(false); }
              else go(current + 1, false);
              later(run, duration + 500);
            });
          }, 300);
        });
      }
      if (on(d.autoplay) && !reduced()) {
        new IntersectionObserver(function (entries) {
          if (stopped) return;
          clearTimeout(timer);
          if (entries[0].isIntersecting) later(run, 700);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-multi-step-form]').forEach(function (el) {
        if (el._mstf) return;
        el._mstf = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<form data-multi-step-form data-success="Thanks! Your account is ready.">
  <fieldset>
    <legend>About you</legend>
    <label>Name <input name="name" required></label>
    <label>Email <input type="email" name="email" required></label>
  </fieldset>
  <fieldset>
    <legend>Your team</legend>
    <label>Company <input name="company"></label>
  </fieldset>
</form>