---
name: morphing-button
description: Use when you want "Modern, smart, polished" - Transforms a button into another shape, loader, or confirmation state.
---

# Morphing Button

> **Category:** Motion / Interaction  -  **Personality:** Modern, smart, polished
>
> **Best use:** Forms, checkout, onboarding
>
> **Ratings:** Professional 5/5 - Casual 4/5 - Exotic 3/5 - Visual impact 4/5 - Difficulty 4/5 - Performance cost 3/5

## What it is

A single submit button that physically **morphs through the states of its own task** instead of throwing up a separate spinner or toast. On click the full-width "Pay" pill collapses into a circular loader, the loader turns green and draws a checkmark on success, then expands back into a "Payment confirmed" pill. Because the same element carries the action *and* its result, the user's eye never leaves the button — which is why it feels so reassuring on the highest-stakes controls in a flow (checkout, sign-up, save). The whole thing is one `<button>` driven by a `data-state` machine; the shape change is just `width` + `border-radius` transitions.

## Dependencies / CDN

None - pure CSS transitions + a tiny vanilla-JS state machine. The demo only loads two display fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Schibsted+Grotesk:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
```

## HTML

One button holding three stacked "faces" (label / spinner / done) plus a live status line. The surrounding checkout card is demo dressing — only this matters:

```html
<div class="mb-payrow">
  <button class="mb-pay" id="mbPay" type="button" data-state="idle" aria-describedby="mbStatus">
    <span class="mb-face mb-f-label">
      <svg class="mb-lock" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="2"/></svg>
      <span>Pay&nbsp;$376.92</span>
    </span>
    <span class="mb-face mb-f-spin" aria-hidden="true">
      <svg class="mb-spin-svg" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="rgba(255,255,255,.25)" stroke-width="2.5"/><path d="M21 12a9 9 0 0 0-9-9" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/></svg>
      <span class="mb-proc">Processing&hellip;</span>
    </span>
    <span class="mb-face mb-f-done">
      <svg class="mb-check" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 12.6l4.4 4.4L19 7.4" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" pathLength="1"/></svg>
      <span class="mb-done-text">Payment confirmed</span>
    </span>
  </button>
</div>
<p class="mb-status" id="mbStatus" aria-live="polite"><i aria-hidden="true"></i><span id="mbStatusText">You won't be charged until you confirm.</span></p>
```

## CSS

The morph is entirely in the four `data-state` rules. Faces cross-fade with `opacity`; the checkmark draws via `pathLength`.

```css
.mb-payrow{display:flex; justify-content:center}            /* keeps the button centred as it shrinks */

.mb-pay{
  position:relative; height:56px; width:100%;                /* idle = full-width pill */
  border:0; border-radius:14px; overflow:hidden; cursor:pointer;
  color:#fff; background-color:#211a20;
  transition:width .55s cubic-bezier(.66,0,.24,1),
             border-radius .55s cubic-bezier(.66,0,.24,1),
             background-color .45s ease, box-shadow .4s ease, transform .16s ease;
  will-change:width,border-radius;
}
.mb-pay::before{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;
  background:linear-gradient(180deg,rgba(255,255,255,.16),transparent 46%)}   /* glossy top sheen */

/* THE MORPH — four states */
.mb-pay[data-state="loading"]{width:56px; border-radius:50%; cursor:default}            /* pill -> circle */
.mb-pay[data-state="success"]{width:56px; border-radius:50%; background-color:#11a073}   /* ink -> emerald */
.mb-pay[data-state="done"]   {width:100%; border-radius:14px; background-color:#11a073}  /* circle -> pill */

/* Stacked faces, cross-faded by state */
.mb-face{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:9px;
  font-weight:700;font-size:15px;opacity:0;transition:opacity .25s ease;pointer-events:none}
.mb-pay[data-state="idle"]    .mb-f-label{opacity:1}
.mb-pay[data-state="loading"] .mb-f-spin {opacity:1}
.mb-pay[data-state="success"] .mb-f-done,
.mb-pay[data-state="done"]    .mb-f-done {opacity:1}

.mb-spin-svg{width:24px;height:24px;animation:mb-spin .8s linear infinite}
@keyframes mb-spin{to{transform:rotate(360deg)}}

/* Checkmark: normalise length to 1, then "draw" by closing the dash gap */
.mb-check{transform:scale(.5);opacity:0;
  transition:transform .4s cubic-bezier(.34,1.56,.64,1) .06s, opacity .2s ease .04s}
.mb-check path{stroke-dasharray:1;stroke-dashoffset:1;transition:stroke-dashoffset .45s ease .1s}
.mb-pay[data-state="success"] .mb-check, .mb-pay[data-state="done"] .mb-check{transform:scale(1);opacity:1}
.mb-pay[data-state="success"] .mb-check path, .mb-pay[data-state="done"] .mb-check path{stroke-dashoffset:0}

/* Confirmation label unfurls only once the pill re-expands */
.mb-done-text{max-width:0;overflow:hidden;white-space:nowrap;opacity:0;
  transition:max-width .45s cubic-bezier(.66,0,.24,1) .04s, opacity .3s ease .12s}
.mb-pay[data-state="done"] .mb-done-text{max-width:220px;opacity:1}
.mb-proc{display:none}                                       /* reduced-motion text fallback (see below) */

/* Reduced motion: no shape change, no spin, no draw — just swap state + text */
@media (prefers-reduced-motion: reduce){
  .mb-pay,
  .mb-pay[data-state="loading"],
  .mb-pay[data-state="success"]{width:100%!important;border-radius:14px!important}
  .mb-spin-svg{display:none} .mb-proc{display:inline}
  .mb-check{transform:none;opacity:1} .mb-check path{stroke-dashoffset:0}
  .mb-done-text{max-width:220px;opacity:1}
}
```

## JavaScript

A guarded state machine. It debits no real payment — `setTimeout` stands in for the network call, and it branches to a minimal path under reduced motion.

```js
(function(){
  var pay=document.getElementById('mbPay');
  var statusEl=document.getElementById('mbStatus');
  var statusText=document.getElementById('mbStatusText');
  if(!pay||!statusText)return;

  var mq=window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)');
  var reduce=mq?mq.matches:false;
  if(mq&&mq.addEventListener)mq.addEventListener('change',function(e){reduce=e.matches;});

  var timers=[];
  function later(fn,ms){timers.push(setTimeout(fn,ms));}
  function clearTimers(){timers.forEach(clearTimeout);timers=[];}
  function setStatus(t,c){statusText.textContent=t;statusEl.className='mb-status'+(c?' '+c:'');}
  function setState(s){pay.setAttribute('data-state',s);}

  function start(){
    if(pay.getAttribute('data-state')!=='idle')return;          // ignore re-clicks mid-flow
    clearTimers();
    pay.disabled=true; pay.setAttribute('aria-busy','true'); pay.setAttribute('aria-label','Processing payment');
    setState('loading'); setStatus('Contacting your bank...','is-load');
    if(reduce){ later(finish,820); }                            // straight to done, no circle phase
    else{ later(function(){setState('success');},1650); later(finish,2320); }
  }
  function finish(){
    setState('done'); pay.removeAttribute('aria-busy'); pay.setAttribute('aria-label','Payment confirmed');
    setStatus('Payment confirmed - Order #HLC-4821','is-done');
  }
  function reset(){                                             // wire to a "Run again" control
    clearTimers(); setState('idle'); pay.disabled=false;
    pay.removeAttribute('aria-busy'); pay.removeAttribute('aria-label');
    setStatus("You won't be charged until you confirm.",''); pay.focus();
  }

  pay.addEventListener('click',start);
})();
```

## How it works

- **One element, four states.** A `data-state` attribute (`idle -> loading -> success -> done`) is the single source of truth. Every visual — width, radius, colour, which face shows — is a CSS rule keyed off that attribute, so JS only ever flips one string.
- **Shape morph = `width` + `border-radius`.** Going `width:100% -> 56px` and `border-radius:14px -> 50%` turns the pill into a circle; reversing it on `done` turns the circle back into a pill. Browsers interpolate the `%`→`px` width via `calc()` blending, so it animates smoothly.
- **Faces, not text swaps.** Three absolutely-positioned `.mb-face` layers (label, spinner, checkmark) are stacked and cross-faded with `opacity`. `overflow:hidden` on the button clips whichever face is wider than the current shape, so the label cleanly disappears as the button shrinks.
- **The checkmark draws itself.** `pathLength="1"` normalises the path to a length of 1 regardless of its real geometry, so `stroke-dasharray:1; stroke-dashoffset:1` hides it and transitioning `stroke-dashoffset` to `0` "draws" it. A springy `scale()` adds the pop.
- **`justify-content:center` on the row** means the shrinking/growing button stays centred instead of collapsing to one edge.

## Customization

- **Timing / drama:** the two `setTimeout`s are the loader duration and the success-circle hold. Shorten them for snappy SaaS saves, lengthen for a deliberate checkout beat.
- **Skip the circle hold:** drop the `success` step and go `loading -> done` for a faster pill→circle→pill in one move.
- **Palette:** change `background-color` per state. Black→emerald reads as "secure → paid"; brand-colour→emerald or indigo→teal work just as well.
- **Result shape:** keep it a circle on `done` (icon-only) instead of re-expanding, if the surrounding UI already says "done".
- **Replayability:** call `reset()` from a "Run again" link, or auto-reset after a few seconds for a self-resetting save button.

## Accessibility & performance

- **Announce, don't just animate.** The status line is an `aria-live="polite"` region and the button's `aria-label` is updated per state, so screen-reader users hear "Processing payment" → "Payment confirmed" even though the visible change is purely graphical.
- **Lock the control while busy.** Setting `disabled` + `aria-busy="true"` on entering `loading` prevents the classic double-charge from an impatient second click; `reset()` re-enables it.
- **Cheap to animate.** Only `width`, `border-radius`, `opacity`, `transform` and `background-color` are transitioned — all compositor/paint-friendly. `will-change:width,border-radius` hints the browser. No layout thrash, no JS animation loop.
- **`prefers-reduced-motion`:** the media query forces the button to stay a full-width pill (no shrink/grow), hides the spinner in favour of a static "Processing..." label, and shows the checkmark without drawing it — the *states* still change, the *motion* doesn't.

## Gotchas

- **Don't drive the flow off `transitionend`.** With reduced motion (and the catalog's global `transition-duration:.001ms`) those events fire almost instantly or can be missed; an explicit `setTimeout` timeline is predictable. Always `clearTimers()` before re-running so a fast replay can't leave a stale timer mid-flight.
- **`overflow:hidden` is required** on the button, or the wide label text spills out of the 56px circle during the collapse.
- **Keep faces as siblings inside the button**, absolutely positioned with `inset:0` — if you let them affect layout, the button height will jump between states.
- **Re-enabling focus:** call `pay.focus()` on reset so keyboard users aren't dropped after the button was disabled.
- **`pathLength` needs a real stroked path** (no `fill`); set `fill="none"` and round line caps/joins or the "draw" looks chopped.
