← Forms

Button to Input Morph

A “Subscribe” button stretches sideways into an email field. The label shrinks into a round arrow button at the end and the field fades in, ready to type. Send a good address and it folds back into a green button that says thanks. A bad one gives the field a red shake.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an email input in an element with data-button-to-input-morph.
  3. Size it with font-size: it’s 3em tall.
  4. Listen for the subscribe event and send e.detail.email to your list.

Options

Add any of these attributes to the same element.

data-labelThe button text. Default Subscribe.
data-doneThe text after sending. Default Subscribed!.
data-widthWidth when open, any CSS length. Never wider than its container. Default 18em.
data-colorButton colour. Default #6a0dad.
data-autoplayIt opens, types a wrong address, fixes it and sends, over and over while in view. It stops as soon as you use it.
data-textWhat autoplay types. Default [email protected].

Enter sends and Escape folds it back. animacious.buttonToInputMorph.reset(el) turns it back into the button.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Button to Input Morph: a "Subscribe" button widens into an email field.
    // Tap it and the pill stretches sideways, the label shrinks away into a
    // round arrow button at the right end and the field fades in ready to
    // type. Send a good address and it folds back into a button that says
    // thanks; a bad one gives the field a shake.
    // Usage: <div data-button-to-input-morph><input type="email" placeholder="Email address"></div>
    //   Size it with font-size: it's 3em tall.
    // Optional: data-label (button text, default "Subscribe"), data-done (text
    // after sending, default "Subscribed!"), data-width (open width, any CSS
    // length, default 18em; never wider than its container), data-color
    // (button colour, default #6a0dad), data-autoplay (it opens, types a wrong
    // address, fixes it and sends, over and over while in view; it stops as
    // soon as you use it), data-text (what autoplay types, default
    // "[email protected]").
    // Listen for the "subscribe" event: e.detail.email is the address.
    // Script use: animacious.buttonToInputMorph.reset(el) turns it back into the button.
    var DEFAULTS = { label: 'Subscribe', done: 'Subscribed!', width: '18em', color: '#6a0dad', text: '[email protected]' };

    var ARROW = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>';
    var TICK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>';

    var css =
      '.bti{position:relative;display:inline-block;box-sizing:border-box;height:3em;max-width:100%;border-radius:1.5em;background:#fff;' +
      'box-shadow:0 0 0 1.5px #dcdce1;vertical-align:middle;text-align:left;transition:box-shadow .25s}' +
      '.bti-open{box-shadow:0 0 0 1.5px var(--bti-color),0 8px 24px rgba(15,15,30,.08)}' +
      '.bti.bti-bad{box-shadow:0 0 0 1.5px #dc2626,0 0 0 5px rgba(220,38,38,.12)}' +
      '.bti-btn{position:absolute;top:0;right:0;display:grid;place-items:center;box-sizing:border-box;width:100%;height:100%;margin:0;padding:0;border:0;' +
      'border-radius:1.5em;background:var(--bti-color);color:#fff;font:inherit;font-weight:600;white-space:nowrap;cursor:pointer;overflow:hidden;' +
      '-webkit-tap-highlight-color:transparent;transition:background-color .35s}' +
      '.bti-btn:focus-visible{outline:2px solid var(--bti-color);outline-offset:3px}' +
      '.bti-done{background:#16a34a}' +
      '.bti-label,.bti-arrow{grid-area:1/1;display:inline-flex;align-items:center;justify-content:center;gap:.45em}' +
      '.bti-label{padding:0 1.5em}.bti-label svg{width:1.1em;height:1.1em}' +
      '.bti-arrow{position:absolute;inset:0;opacity:0}.bti-arrow svg{width:1.15em;height:1.15em}' +
      '.bti-measure{visibility:hidden;pointer-events:none;width:auto}' +
      '.bti-field{position:absolute;inset:0;box-sizing:border-box;width:100%;height:100%;margin:0;padding:0 3.4em 0 1.25em;border:0;border-radius:inherit;' +
      'background:transparent;color:inherit;font:inherit;line-height:3em;outline:none;opacity:0;visibility:hidden;white-space:pre;overflow:hidden;' +
      '-webkit-appearance:none;appearance:none;box-shadow:none}' +
      '.bti-field::placeholder{color:#a1a1aa}' +
      '.bti-field[data-placeholder]:not(.bti-filled)::before{content:attr(data-placeholder);color:#a1a1aa}' +
      '.bti-caret{display:inline-block;width:1.5px;height:1.1em;margin-left:1px;vertical-align:-0.2em;background:currentColor;animation:bti-blink 1s steps(1) infinite}' +
      '.bti-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}' +
      '@keyframes bti-blink{50%{opacity:0}}';
    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 good(v) { return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v); }

    function setup(el) {
      var d = el.dataset;
      var mock = !!el.closest('[aria-hidden="true"]');
      var input = mock ? null : el.querySelector('input');
      var placeholder = (input && input.getAttribute('placeholder')) || 'Email address';
      var labelText = d.label || DEFAULTS.label, doneText = d.done || DEFAULTS.done;
      el.classList.add('bti');
      el.style.setProperty('--bti-color', d.color || DEFAULTS.color);

      var field, text = null, caret = null;
      if (input) {
        field = input;
        if (!input.getAttribute('aria-label')) input.setAttribute('aria-label', placeholder);
        input.tabIndex = -1;
      } else {
        el.textContent = '';
        field = document.createElement('span');
        field.setAttribute('data-placeholder', placeholder);
        text = document.createElement('span');
        field.appendChild(text);
      }
      field.classList.add('bti-field');
      el.appendChild(field);

      var btn = document.createElement(mock ? 'span' : 'button');
      if (!mock) btn.type = 'button';
      btn.className = 'bti-btn';
      btn.innerHTML = '<span class="bti-label"></span><span class="bti-arrow">' + ARROW + '</span>';
      var label = btn.firstChild, arrow = btn.lastChild;
      el.appendChild(btn);
      var meas = document.createElement('span');
      meas.className = 'bti-btn bti-measure';
      meas.setAttribute('aria-hidden', 'true');
      meas.innerHTML = '<span class="bti-label"></span>';
      el.appendChild(meas);
      var live = null;
      if (!mock) {
        live = document.createElement('span');
        live.className = 'bti-sr';
        live.setAttribute('role', 'status');
        el.appendChild(live);
      }

      function fill(node, done) {
        node.innerHTML = done ? TICK : '';
        node.appendChild(document.createTextNode(done ? doneText : labelText));
      }
      function widthOf(done) { fill(meas.firstChild, done); return Math.ceil(meas.offsetWidth); }
      fill(label, false);

      var get = function () { return input ? input.value : text.textContent; };
      var set = function (v) {
        if (input) input.value = v;
        else {
          text.textContent = v;
          field.classList.toggle('bti-filled', !!v);
          if (v && !caret) { caret = document.createElement('span'); caret.className = 'bti-caret'; field.appendChild(caret); }
          if (!v && caret) { caret.remove(); caret = null; }
        }
      };

      var state = 'closed';
      el.style.width = widthOf(false) + 'px';
      function knob() {
        var h = el.offsetHeight, s = Math.round(h * 0.76);
        return { h: h, s: s, i: Math.round((h - s) / 2) };
      }
      function openWidth() {
        var prev = el.style.width;
        el.style.width = d.width || DEFAULTS.width;
        var w = el.offsetWidth;
        el.style.width = prev;
        return w;
      }
      function halt() { anime.utils.remove([el, btn, label, arrow, field, label.firstChild].filter(Boolean)); }

      function open(focus) {
        if (state === 'open') return;
        halt();
        state = 'open';
        var from = el.offsetWidth, w = openWidth(), k = knob();
        el.classList.add('bti-open');
        btn.classList.remove('bti-done');
        field.style.visibility = 'visible';
        if (input) {
          input.tabIndex = 0;
          btn.setAttribute('aria-label', 'Send');
          if (focus) input.focus();
        }
        var end = { width: k.s + 'px', height: k.s + 'px', top: k.i + 'px', right: k.i + 'px' };
        if (reduced()) {
          el.style.width = w + 'px';
          anime.utils.set(btn, end);
          anime.utils.set(label, { opacity: 0 });
          anime.utils.set(arrow, { opacity: 1, scale: 1, rotate: 0 });
          anime.utils.set(field, { opacity: 1 });
          return;
        }
        var room = el.parentNode.clientWidth > w * 1.08;
        anime.animate(el, { width: [from, w], duration: room ? 700 : 450, ease: room ? 'outElastic(1, .85)' : 'out(4)' });
        anime.animate(btn, { width: [from, k.s], height: [k.h, k.s], top: [0, k.i], right: [0, k.i], duration: 520, ease: 'out(4)' });
        anime.animate(label, { opacity: 0, scale: 0.8, duration: 160, ease: 'out(2)' });
        anime.animate(arrow, { opacity: [0, 1], scale: [0.4, 1], rotate: [-60, 0], duration: 460, delay: 260, ease: 'outBack(2)' });
        anime.animate(field, { opacity: [0, 1], duration: 300, delay: 200, ease: 'out(2)' });
      }

      // Fold back into the button: with a thank-you (done) or the plain label.
      function close(done) {
        if (state === 'closed' || (state === 'done' && done)) return;
        var was = state;
        halt();
        state = done ? 'done' : 'closed';
        var from = el.offsetWidth, to = widthOf(done), k = knob();
        el.classList.remove('bti-open', 'bti-bad');
        btn.classList.toggle('bti-done', done);
        if (input) {
          input.tabIndex = -1;
          btn.removeAttribute('aria-label');
          if (document.activeElement === input) input.blur();
        }
        var fin = function () {
          field.style.visibility = 'hidden';
          set('');
          anime.utils.set(btn, { width: '100%', height: '100%', top: '0px', right: '0px' });
          el.style.width = to + 'px';
        };
        if (reduced()) {
          fill(label, done);
          anime.utils.set(label, { opacity: 1, scale: 1 });
          anime.utils.set([arrow, field], { opacity: 0 });
          fin();
          return;
        }
        if (was === 'done') {
          // From the thank-you back to the label: a quick crossfade.
          anime.animate(label, {
            opacity: 0, scale: 0.85, duration: 180, ease: 'in(2)',
            onComplete: function () {
              fill(label, false);
              anime.animate(el, { width: to, duration: 420, ease: 'inOut(3)', onComplete: fin });
              anime.animate(label, { opacity: [0, 1], scale: [0.85, 1], duration: 300, delay: 160, ease: 'out(3)' });
            }
          });
          return;
        }
        fill(label, done);
        anime.animate([field, arrow], { opacity: 0, duration: 160, ease: 'in(2)' });
        anime.animate(el, { width: [from, to], duration: 520, delay: 80, ease: 'inOut(3)' });
        anime.animate(btn, { width: [k.s, to], height: [k.s, k.h], top: [k.i, 0], right: [k.i, 0], duration: 520, delay: 80, ease: 'inOut(3)', onComplete: fin });
        anime.animate(label, { opacity: [0, 1], scale: [0.8, 1], duration: 320, delay: 400, ease: 'out(3)' });
        if (done) anime.animate(label.firstChild, { scale: [0, 1], rotate: [-40, 0], duration: 520, delay: 480, ease: 'outBack(3)' });
      }

      var badT = null;
      function submit(user) {
        if (state !== 'open') return;
        var v = get().trim();
        if (!good(v)) {
          el.classList.add('bti-bad');
          clearTimeout(badT);
          badT = setTimeout(function () { el.classList.remove('bti-bad'); }, 1300);
          if (live) live.textContent = 'Enter a valid email address.';
          if (!reduced()) anime.animate(el, { x: [0, -9, 8, -6, 4, -2, 0], duration: 480, ease: 'out(2)' });
          if (user && input) input.focus();
          return;
        }
        if (live) live.textContent = doneText;
        el.dispatchEvent(new CustomEvent('subscribe', { bubbles: true, detail: { email: v } }));
        close(true);
      }

      el._btiReset = function () { close(false); };

      if (input) {
        btn.addEventListener('click', function () {
          stop();
          if (state === 'open') submit(true);
          else if (state === 'closed') open(true);
        });
        input.addEventListener('keydown', function (e) {
          if (e.key === 'Enter') { e.preventDefault(); submit(true); }
          if (e.key === 'Escape') { close(false); btn.focus(); }
        });
        input.addEventListener('blur', function () {
          setTimeout(function () { if (state === 'open' && !get() && !el.contains(document.activeElement)) close(false); }, 150);
        });
        el.addEventListener('pointerdown', stop);
        input.addEventListener('focus', stop);
      }

      // Refit when the container changes width, and once web fonts arrive.
      var lastW = 0;
      if (window.ResizeObserver) new ResizeObserver(function (entries) {
        var w = entries[0].contentRect.width;
        if (w === lastW) return;
        lastW = w;
        requestAnimationFrame(function () { if (state === 'open') el.style.width = openWidth() + 'px'; });
      }).observe(el.parentNode);
      if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () {
        if (state !== 'open') el.style.width = widthOf(state === 'done') + 'px';
      });

      var timer = null, stopped = false;
      function stop() { stopped = true; clearTimeout(timer); }
      if (on(d.autoplay) && !reduced()) {
        var word = d.text || DEFAULTS.text;
        var at = word.indexOf('@'), dot = word.lastIndexOf('.');
        var cut = dot > at && at > 0 ? dot : -1; // first try leaves off the ending, so it shakes
        var steps = [[function () { open(false); }, 800]];
        var type = function (n) { steps.push([function () { set(word.slice(0, n)); }, 60 + Math.random() * 80]); };
        for (var i = 1; i <= word.length; i++) {
          type(i);
          if (i === cut) steps.push([function () {}, 450], [function () { submit(false); }, 1000]);
        }
        steps.push([function () {}, 450]);
        steps.push([function () { submit(false); }, 2400]);
        steps.push([function () { close(false); }, 1300]);
        var run = function (i) {
          if (stopped) return;
          steps[i][0]();
          timer = setTimeout(function () { run((i + 1) % steps.length); }, steps[i][1]);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (stopped) return;
          if (!entries[0].isIntersecting) return;
          var wait = state === 'closed' ? 500 : 1300;
          close(false);
          timer = setTimeout(function () { run(0); }, wait);
        }).observe(el);
      }
    }

    window.animacious = window.animacious || {};
    window.animacious.buttonToInputMorph = { reset: function (el) { if (el && el._btiReset) el._btiReset(); } };

    function init() {
      document.querySelectorAll('[data-button-to-input-morph]').forEach(function (el) {
        if (el._bti) return;
        el._bti = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-button-to-input-morph data-label="Get updates">
  <input type="email" placeholder="Email address">
</div>

<script>
  document.querySelector('[data-button-to-input-morph]').addEventListener('subscribe', function (e) {
    fetch('/subscribe', { method: 'POST', body: e.detail.email });
  });
</script>