You Might Also Like
Button Loading State Morph — Shape-Shifting Submit Button in HTML CSS JS
Button Loading State Morph · Loaders · Plain HTML, CSS & JS · Live preview
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.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Instead of tracing the three-state class-swapping logic yourself, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how animating the button's own width and border-radius (rather than swapping an icon inside a fixed-size button) produces the pill-to-circle morph, and how the checkmark's stroke-dasharray/stroke-dashoffset combination makes it look hand-drawn rather than faded in. The same assistant can help optimize it — for instance asking whether the two chained setTimeout calls should instead be replaced with a real Promise-based fetch call with proper .catch() error handling, since right now there is no error state. It's also useful for extending the pattern: ask it to add a fourth "error" morph state (perhaps a shake animation and red background), make the revert-to-idle delay configurable, or generalize the component into a reusable function that takes a promise and drives the three states automatically. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a form submit button whose own shape morphs through loading and success states in plain HTML, CSS, and JavaScript — no animation library.
Requirements:
- A pill-shaped button with a text label, containing (but not initially showing) an inline SVG spinner ring and a separate inline SVG checkmark, both absolutely positioned inside the button.
- On click/submit, the button itself must animate its own width and border-radius via a CSS transition (not a swapped icon inside a fixed-size button) from the full pill shape down to a small circle, while the text label fades out and the spinner fades in and spins continuously — driven by adding a "loading" class via JavaScript, and the button must be disabled for the duration to prevent repeat submissions.
- Simulate an async operation with a timeout structured so it is obvious where a real fetch or form-submission promise would be substituted in.
- When the simulated request resolves, swap to a distinct "success" state (still applied via a class toggle): keep the circular shape, fade out the spinner, and reveal a checkmark icon using an SVG stroke-dasharray/stroke-dashoffset animation so the checkmark appears to draw itself stroke-by-stroke rather than simply fading in, with the button's background color changing to a success color.
- After a pause in the success state, automatically revert the button back to its original pill shape and label, and re-enable it, so the whole three-stage cycle (idle to loading to success to idle) can be triggered again.
- Every shape and color transition must be driven by CSS transitions/keyframes reacting to JavaScript-toggled classes, not by JavaScript manually animating style properties frame by frame.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
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
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.