webdevpuneet.com

Button Loading State Morph — Shape-Shifting Submit Button in HTML CSS JS

Button Loading State Morph · Loaders · Plain HTML, CSS & JS · Live preview

CategoryLoaders

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>

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.

· Become a supporter

What's included

Features

Real width/border-radius morph
The button's own shape animates from a pill to a circle, not just its icon.
Three genuine states
Idle, loading, and success are distinct classes with distinct visuals.
Self-drawing checkmark
An SVG stroke-dashoffset animation reveals the check like it's being drawn.
Click-triggered, not decorative
Every transition is tied to a real click handler and async lifecycle.
Disabled during work
The button disables itself while loading to prevent double-submission.
Auto-reverting
Success reverts back to the idle label automatically after a pause.
GPU-friendly spinner
The ring uses stroke-dashoffset + rotate, both compositor-friendly.
Zero dependencies
Pure HTML, CSS transitions/keyframes, and vanilla JS — no library.

About this UI Snippet

Button Loading State Morph — A Button That Shrinks Into a Spinner, Then a Checkmark

Screenshot of the Button Loading State Morph snippet rendered live

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

  1. 1
    Paste HTML, CSS, and JSA pill-shaped "Submit order" button renders, ready to click.
  2. 2
    Click the buttonIts width animates down into a circle as the label fades and a spinner appears.
  3. 3
    Watch it resolveAfter ~1.7s, the spinner swaps for a drawn checkmark and the circle turns green.
  4. 4
    Watch it revertAfter another ~1.8s, the button eases back to its original pill and label.
  5. 5
    Wire it to a real requestReplace the two setTimeout calls with your fetch/submit promise's resolve and settle.
  6. 6
    Restyle itChange the idle/loading/success colors, the pill width, or the timing constants.

Real-world uses

Common Use Cases

Checkout and order submission
Morph the pay button from a pill into a small circle spinner on click, then into a checkmark when the order is confirmed.
Settings and form saves
Offer a more expressive alternative to a loading button when saving preferences, with every state tied to a real click handler.
Newsletter and signup confirmations
Confirm a subscription with a self-drawing check, using stroke-dashoffset to reveal the tick as if it were being written.
Payment confirmation buttons
Reassure users during a payment by shrinking the target area, which also prevents a second accidental click while processing.
Multi-step form actions
Use as the final submit inside a multi-step form, animating width and border radius to change the button's own shape.

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.