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
- Paste the snippet just before
</head>. - Wrap an email input in an element with
data-button-to-input-morph. - Size it with
font-size: it’s 3em tall. - Listen for the
subscribeevent and sende.detail.emailto your list.
Options
Add any of these attributes to the same element.
data-label | The button text. Default Subscribe. |
data-done | The text after sending. Default Subscribed!. |
data-width | Width when open, any CSS length. Never wider than its container. Default 18em. |
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]. |
Enter sends and Escape folds it back. animacious.buttonToInputMorph.reset(el) turns it back into the button.
<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><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>