Button Loading
Button Loading
A button that morphs through three states — idle, loading (spinner), success (checkmark) — without layout shift. Motion One animates the width collapse and color transition.
Dependencies
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Button Loading — Motion Recipe</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/motion@11.11.13/dist/motion.js"></script>
<style>
.btn-spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
}
@keyframes spin { to { transform: rotate(360deg); } }
.spinning { animation: spin 0.7s linear infinite; }
</style>
</head>
<body class="bg-slate-950 text-slate-200 font-sans">
<div class="mx-auto max-w-3xl px-8 pt-20 pb-32">
<span class="mb-6 inline-block rounded-full border border-violet-500/20 bg-violet-500/10 px-3.5 py-1 text-[0.7rem] font-semibold uppercase tracking-widest text-violet-400">Button Loading</span>
<h1 class="mb-4 text-4xl font-black leading-tight text-slate-100">Submit → Spinner → Success.</h1>
<p class="mb-12 max-w-sm text-base leading-relaxed text-slate-400">A button that morphs through three states — idle, loading, done — without layout shift. Motion One handles the width animation.</p>
<!-- Demo 1: Pay button -->
<div class="mb-16">
<p class="mb-4 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Payment button</p>
<div class="flex items-center gap-6">
<button id="pay-btn"
class="relative flex items-center justify-center overflow-hidden rounded-2xl bg-indigo-600 px-8 py-4 font-bold text-white text-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
style="min-width: 160px;"
aria-live="polite">
<span id="pay-label">Pay $49</span>
<span id="pay-spinner" class="btn-spinner hidden" aria-hidden="true"></span>
<svg id="pay-check" class="hidden h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
</svg>
<span class="sr-only" id="pay-sr"></span>
</button>
<p class="text-sm text-slate-500">Click to trigger</p>
</div>
</div>
<!-- Demo 2: Form submit -->
<div>
<p class="mb-4 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Form submit</p>
<form id="contact-form" class="flex flex-col gap-4 max-w-sm" onsubmit="return false;">
<input type="email" placeholder="you@example.com" required
class="rounded-xl border border-slate-700 bg-slate-900 px-4 py-3 text-sm text-slate-200 placeholder-slate-600 focus:border-violet-500 focus:outline-none">
<button id="submit-btn" type="submit"
class="relative flex items-center justify-center rounded-xl bg-violet-600 px-6 py-3 font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
style="min-width: 140px;"
aria-live="polite">
<span id="submit-label">Subscribe</span>
<span id="submit-spinner" class="btn-spinner hidden" aria-hidden="true"></span>
<svg id="submit-check" class="hidden h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
</svg>
</button>
</form>
</div>
</div>
<script>
var { animate } = Motion;
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SPRING = [0.16, 1, 0.3, 1];
function morphButton(btn, label, spinner, check, srEl) {
if (btn.dataset.loading === 'true') return;
btn.dataset.loading = 'true';
btn.disabled = true;
// → Loading
var startW = btn.offsetWidth;
label.classList.add('hidden');
spinner.classList.remove('hidden');
spinner.classList.add('spinning');
if (srEl) srEl.textContent = 'Processing…';
if (!reduced) {
animate(btn, { width: ['auto', startW + 'px', '3rem'] }, {
duration: 0.5,
easing: SPRING,
});
} else {
btn.style.width = '3rem';
}
// → Success (after 2s)
setTimeout(function() {
spinner.classList.add('hidden');
spinner.classList.remove('spinning');
check.classList.remove('hidden');
if (srEl) srEl.textContent = 'Done!';
if (!reduced) {
animate(btn,
{ backgroundColor: ['#4f46e5', '#16a34a'], width: ['3rem', startW + 'px'] },
{ duration: 0.45, easing: SPRING }
);
animate(check,
{ scale: [0.3, 1.15, 1], opacity: [0, 1] },
{ duration: 0.4, easing: SPRING }
);
} else {
btn.style.backgroundColor = '#16a34a';
btn.style.width = 'auto';
}
// → Reset (after 1.5s)
setTimeout(function() {
check.classList.add('hidden');
label.classList.remove('hidden');
if (srEl) srEl.textContent = '';
btn.dataset.loading = 'false';
btn.disabled = false;
if (!reduced) {
animate(btn,
{ backgroundColor: btn.id === 'pay-btn' ? '#4f46e5' : '#7c3aed' },
{ duration: 0.3, easing: 'ease' }
);
} else {
btn.style.backgroundColor = '';
btn.style.width = '';
}
}, 1500);
}, 2000);
}
document.getElementById('pay-btn').addEventListener('click', function() {
morphButton(this,
document.getElementById('pay-label'),
document.getElementById('pay-spinner'),
document.getElementById('pay-check'),
document.getElementById('pay-sr')
);
});
document.getElementById('contact-form').addEventListener('submit', function() {
morphButton(
document.getElementById('submit-btn'),
document.getElementById('submit-label'),
document.getElementById('submit-spinner'),
document.getElementById('submit-check'),
null
);
});
</script>
</body>
</html>
The button collapses its width to a circle (spinner phase), then expands back while transitioning to green (success phase), then resets. The aria-live="polite" region announces state changes to screen readers.
Width is captured from the DOM at click time (btn.offsetWidth) — no hardcoded values. This makes the recipe work with any button label length.
Motion One animates width through three keyframes in the collapse phase: ['auto', startW + 'px', '3rem']. Passing explicit units ensures the animation engine treats them as the same property type. The success expand uses the inverse: ['3rem', startW + 'px'].
prefers-reduced-motion skips all transforms and background color transitions — the button shows the spinner and checkmark with no animation, then resets identically.
The btn.dataset.loading === 'true' guard prevents double-firing on rapid clicks. btn.disabled = true during loading prevents form resubmission and removes the element from focus order.