Button Loading State Morph — Shape-Shifting Submit Button in HTML CSS JS
Button Loading State Morph · Loaders · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<form class="bm-form" id="bmForm">
<button type="submit" class="bm-btn" id="bmBtn">
<span class="bm-label">Submit order</span>
<svg class="bm-spinner" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"></circle></svg>
<svg class="bm-check" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"></path></svg>
</button>
</form>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.bm-form{display:flex}
.bm-btn{position:relative;width:180px;height:48px;border-radius:24px;border:none;background:#6366f1;color:#fff;font-size:14.5px;font-weight:700;cursor:pointer;font-family:inherit;
transition:width .35s cubic-bezier(.65,0,.35,1),border-radius .35s cubic-bezier(.65,0,.35,1),background .3s;
display:flex;align-items:center;justify-content:center;overflow:hidden}
.bm-btn:hover{background:#4f46e5}
.bm-btn:disabled{cursor:not-allowed}
.bm-label{transition:opacity .15s;white-space:nowrap}
.bm-spinner,.bm-check{position:absolute;width:20px;height:20px;opacity:0;transition:opacity .15s}
.bm-spinner{fill:none;stroke:#fff;stroke-width:2.5;stroke-linecap:round;stroke-dasharray:42;stroke-dashoffset:32}
.bm-check{fill:none;stroke:#fff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:24;stroke-dashoffset:24}
/* Loading: shrink to a circle, hide the label, show the spinning ring. */
.bm-btn.bm-loading{width:48px;border-radius:50%;background:#4f46e5}
.bm-btn.bm-loading .bm-label{opacity:0}
.bm-btn.bm-loading .bm-spinner{opacity:1;animation:bmSpin 0.85s linear infinite}
@keyframes bmSpin{to{transform:rotate(360deg)}}
/* Success: stay a circle, swap spinner for a drawn checkmark, go green. */
.bm-btn.bm-success{width:48px;border-radius:50%;background:#16a34a}
.bm-btn.bm-success .bm-label{opacity:0}
.bm-btn.bm-success .bm-spinner{opacity:0}
.bm-btn.bm-success .bm-check{opacity:1;animation:bmDraw .4s ease forwards}
@keyframes bmDraw{to{stroke-dashoffset:0}}var form = document.getElementById('bmForm');
var btn = document.getElementById('bmBtn');
var label = btn.querySelector('.bm-label');
form.addEventListener('submit', function (e) {
e.preventDefault();
if (btn.classList.contains('bm-loading') || btn.classList.contains('bm-success')) return;
// Stage 1: pill -> circle, label fades, spinner appears.
btn.disabled = true;
btn.classList.add('bm-loading');
// Simulate a real async request — replace with your actual fetch/submit promise.
setTimeout(function () {
// Stage 2: circle stays a circle, spinner swaps for a drawn checkmark.
btn.classList.remove('bm-loading');
btn.classList.add('bm-success');
setTimeout(function () {
// Stage 3: revert back to the original pill with its label.
btn.classList.remove('bm-success');
label.textContent = 'Submit order';
btn.disabled = false;
}, 1800);
}, 1700);
});Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Button Loading State Morph — A Button That Shrinks Into a Spinner, Then a Checkmark

Most button loading states just swap an icon inside a fixed-size button. This one actually changes the button's own shape: on click, its width animates down from a full pill to a small circle while a spinner fades in inside that circle, then — once the simulated request resolves — the spinner swaps for a hand-drawn checkmark and the circle turns green, before reverting to the original label. It's a real width/border-radius transition tied to actual click-triggered state, not a decorative icon swap.
Three real states, one element
The button cycles through three CSS classes applied by JavaScript in sequence: idle (the full pill with its label), .bm-loading (width collapses to 48px, border-radius becomes 50%, the label fades out, an SVG ring spins in), and .bm-success (stays a circle, the spinner fades out, an SVG checkmark path animates its stroke-dashoffset from full to zero — a hand-drawn reveal — and the background turns green). Each transition is driven by transition: width .35s, border-radius .35s, so the shape change is a genuine animated morph, not an instant swap.
The checkmark draws itself
The check icon starts with stroke-dasharray: 24; stroke-dashoffset: 24 — the full path length pulled off-screen — and the bmDraw keyframe animates stroke-dashoffset to 0, which is the classic SVG line-drawing technique. Combined with the circle popping green underneath it, the success state reads as a deliberate confirmation rather than just another static icon.
A realistic async lifecycle
The click handler is structured exactly like a real submit flow: btn.disabled = true and the loading class go on immediately, a setTimeout stands in for your actual fetch/form-submission promise, then on "resolve" the success class takes over, and a second timeout reverts everything back to idle and re-enables the button. Swap the two setTimeout calls for your real request's .then()/.finally() and the shape-morph logic needs no other changes.
Drop-in and restyleable
Recolor the loading and success backgrounds, change the idle pill's width or corner radius, or adjust the timing constants. Pair it with a loading button for a simpler icon-swap alternative, or a multi-step form where this becomes the final submit control.
Step by step
How to Use
- 1Paste HTML, CSS, and JSA pill-shaped "Submit order" button renders, ready to click.
- 2Click the buttonIts width animates down into a circle as the label fades and a spinner appears.
- 3Watch it resolveAfter ~1.7s, the spinner swaps for a drawn checkmark and the circle turns green.
- 4Watch it revertAfter another ~1.8s, the button eases back to its original pill and label.
- 5Wire it to a real requestReplace the two setTimeout calls with your fetch/submit promise's resolve and settle.
- 6Restyle itChange the idle/loading/success colors, the pill width, or the timing constants.
Real-world uses
Common Use Cases
stroke-dashoffset to reveal the tick as if it were being written.Got questions?
Frequently Asked Questions
The button's width and border-radius are set explicitly in the .bm-loading and .bm-success classes (48px and 50% respectively), and both properties have a CSS transition on the base .bm-btn rule. Toggling the class with JavaScript on click triggers a real animated interpolation between the pill and circle dimensions — it is not a swapped background image or icon.
The check SVG path starts with stroke-dasharray and stroke-dashoffset both set to the path's total length, which hides the entire stroke off the visible dash. The bmDraw keyframe animates stroke-dashoffset down to 0, progressively revealing the path from one end to the other — the standard SVG line-drawing technique, distinct from an opacity fade.
In the submit handler, keep the immediate btn.disabled = true and btn.classList.add('bm-loading'), then replace the first setTimeout with your actual fetch or async submit call. On success, run the code that adds bm-success and removes bm-loading inside your .then(); on failure, add an error state instead. Use .finally() to guarantee the button never gets stuck loading.
btn.disabled = true prevents the browser from firing another click/submit event while a request is already in flight, which stops duplicate order submissions if the user clicks impatiently. The .bm-loading class also sets cursor: not-allowed as a visual cue, but the disabled attribute is what actually blocks the interaction at the browser level.
Track a status state (idle/loading/success) and derive the class list from it. In React: const [status, setStatus] = useState('idle'); on submit call setStatus('loading'), await your request, then setStatus('success'), and a setTimeout back to 'idle'. Keep disabled={status !== 'idle'} on the button. The CSS transitions and SVG animations port unchanged.