Tailwind UI Pattern Registry for humans and agents

button loading spinner success state morphing micro-interaction motion-one motion-one

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.

Motion One Clicksubmit
Live Preview

Dependencies

Motion v11.11.13
HTML
<!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.