Experiment #011 · New
Success Morph Button
A three-state Alpine machine (idle → loading → success) drives the whole sequence: on submit the label fades out and the pill contracts into a circle via an animated max-width while a spinner takes over. Once the work resolves, the circle turns green and the checkmark path draws itself with a stroke-dashoffset keyframe, then everything springs back to idle after two seconds. Only max-width, border-radius, background-color, opacity and transform are ever transitioned.
Technical details
- Experiment
- FX-011
- Category
- Forms
- Stack
- Tailwind + Alpine.js + CSS
- Difficulty
- Advanced
- Dependencies
- none
- Weight
- HTML 4.1 KB · ALPINE 1.6 KB · CSS 3.6 KB
- Added
- 2026-08-30
- Updated
- 2026-08-30
Lab notes
Wire submit() to a real request: replace the simulated timeout with an awaited fetch and flip to success when it resolves (bail back to idle on failure). Keep the collapsed max-width equal to the button height so the pill lands on a perfect circle. pathLength="24" normalizes the checkmark, so the dash values never need recomputing if you swap the path.
Take the code
<!-- Success Morph Button — Tailwind CSS + Alpine.js -->
<form
x-data="{
state: 'idle',
timers: [],
submit() {
if (this.state !== 'idle') return
this.state = 'loading'
/* Replace this timeout with your real request, e.g.
await fetch('/save', { method: 'POST', body: new FormData(this.$el) })
then flip to 'success' (or back to 'idle' on failure). */
this.timers.push(setTimeout(() => {
this.state = 'success'
this.timers.push(setTimeout(() => { this.state = 'idle' }, 2000))
}, 1400))
},
destroy() {
this.timers.forEach(clearTimeout)
},
}"
@submit.prevent="submit"
>
<button
type="submit"
:aria-busy="state === 'loading'"
:class="{
'max-w-44 rounded-md bg-zinc-900 hover:bg-zinc-700': state === 'idle',
'max-w-11 rounded-full bg-zinc-900': state === 'loading',
'max-w-11 rounded-full bg-emerald-600': state === 'success',
}"
class="relative h-11 w-44 cursor-pointer overflow-hidden text-sm font-medium text-white
transition-[max-width,border-radius,background-color] duration-300 ease-in-out
focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-zinc-500
motion-reduce:transition-none"
>
<!-- Label: fades and shrinks out, but stays in the DOM so the
button keeps its accessible name. -->
<span
:class="state === 'idle' ? 'opacity-100 scale-100' : 'opacity-0 scale-75'"
class="absolute inset-0 flex items-center justify-center whitespace-nowrap
transition-[opacity,transform] duration-200 motion-reduce:transition-none"
>
Save changes
</span>
<!-- Spinner: a quarter arc over a faint track. -->
<svg
:class="state === 'loading' ? 'opacity-100' : 'opacity-0'"
class="absolute inset-0 m-auto h-5 w-5 animate-spin opacity-0 transition-opacity duration-200
motion-reduce:animate-none motion-reduce:transition-none"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-opacity="0.25" stroke-width="2.5" />
<path d="M12 3a9 9 0 0 1 9 9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
</svg>
<!-- Checkmark: pathLength normalizes the path to 24 units, so the
dash values below work for any check shape you draw. -->
<svg
:class="state === 'success' ? 'opacity-100' : 'opacity-0'"
class="absolute inset-0 m-auto h-5 w-5 opacity-0"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M5 13l4 4L19 7"
pathLength="24"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="fx-success-morph-button-check"
:class="state === 'success' && 'fx-success-morph-button-draw'"
/>
</svg>
</button>
<!-- Announce state changes to screen readers. -->
<span
role="status"
class="sr-only"
x-text="state === 'loading' ? 'Saving' : state === 'success' ? 'Saved' : ''"
></span>
</form>
<style>
.fx-success-morph-button-check {
stroke-dasharray: 24;
}
/* 'backwards' keeps the path hidden during the 0.2s delay while the
circle finishes turning green. Removing the class resets the draw. */
.fx-success-morph-button-draw {
animation: fx-success-morph-button-draw 0.4s ease-out 0.2s backwards;
}
@keyframes fx-success-morph-button-draw {
from { stroke-dashoffset: 24; }
to { stroke-dashoffset: 0; }
}
@media (prefers-reduced-motion: reduce) {
.fx-success-morph-button-draw {
animation: none;
}
}
</style>
// Success Morph Button — reusable Alpine.js component.
//
// Register once, before Alpine.start():
//
// import successMorphButton from './success-morph-button'
// Alpine.data('successMorphButton', successMorphButton)
//
// Then in the markup:
//
// <form x-data="successMorphButton({ action: () => fetch('/save', { method: 'POST' }) })"
// @submit.prevent="submit">
//
// Without an action, the loading state is simulated for `loadingMs`.
export default function successMorphButton({ action = null, loadingMs = 1400, resetMs = 2000 } = {}) {
return {
state: 'idle',
timers: [],
get idle() {
return this.state === 'idle'
},
get loading() {
return this.state === 'loading'
},
get success() {
return this.state === 'success'
},
async submit() {
if (this.state !== 'idle') return
this.state = 'loading'
if (typeof action === 'function') {
try {
await action()
} catch (error) {
this.state = 'idle'
throw error
}
} else {
await this.wait(loadingMs)
}
this.state = 'success'
await this.wait(resetMs)
this.state = 'idle'
},
wait(ms) {
return new Promise((resolve) => this.timers.push(setTimeout(resolve, ms)))
},
destroy() {
this.timers.forEach(clearTimeout)
},
}
}
/* Success Morph Button — vanilla CSS (no Tailwind).
*
* Markup:
*
* <button type="submit" class="fx-success-morph-button">
* <span class="fx-success-morph-button__label">Save changes</span>
* <svg class="fx-success-morph-button__spinner" viewBox="0 0 24 24" fill="none" aria-hidden="true">
* <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-opacity="0.25" stroke-width="2.5" />
* <path d="M12 3a9 9 0 0 1 9 9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
* </svg>
* <svg class="fx-success-morph-button__check" viewBox="0 0 24 24" fill="none" aria-hidden="true">
* <path d="M5 13l4 4L19 7" pathLength="24" stroke="currentColor" stroke-width="2.5"
* stroke-linecap="round" stroke-linejoin="round" />
* </svg>
* </button>
*
* Drive it from your own JS by toggling classes on the button:
* none (idle) → .is-loading → .is-success → none.
*/
.fx-success-morph-button {
position: relative;
box-sizing: border-box;
width: 176px;
max-width: 176px;
height: 44px;
overflow: hidden;
border: none;
border-radius: 6px;
background-color: #18181b;
color: #ffffff;
font: 500 14px/1 system-ui, sans-serif;
cursor: pointer;
transition:
max-width 0.3s ease-in-out,
border-radius 0.3s ease-in-out,
background-color 0.3s ease-in-out;
}
.fx-success-morph-button:hover {
background-color: #3f3f46;
}
.fx-success-morph-button.is-loading,
.fx-success-morph-button.is-success {
max-width: 44px;
border-radius: 9999px;
}
.fx-success-morph-button.is-success {
background-color: #059669;
}
.fx-success-morph-button.is-loading:hover {
background-color: #18181b;
}
.fx-success-morph-button.is-success:hover {
background-color: #059669;
}
.fx-success-morph-button__label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.fx-success-morph-button.is-loading .fx-success-morph-button__label,
.fx-success-morph-button.is-success .fx-success-morph-button__label {
opacity: 0;
transform: scale(0.75);
}
.fx-success-morph-button__spinner,
.fx-success-morph-button__check {
position: absolute;
inset: 0;
width: 20px;
height: 20px;
margin: auto;
opacity: 0;
transition: opacity 0.2s ease;
}
.fx-success-morph-button.is-loading .fx-success-morph-button__spinner {
opacity: 1;
animation: fx-success-morph-button-spin 0.8s linear infinite;
}
.fx-success-morph-button__check path {
stroke-dasharray: 24;
}
.fx-success-morph-button.is-success .fx-success-morph-button__check {
opacity: 1;
}
/* 'backwards' keeps the path hidden during the 0.2s delay while the
circle finishes turning green. */
.fx-success-morph-button.is-success .fx-success-morph-button__check path {
animation: fx-success-morph-button-draw 0.4s ease-out 0.2s backwards;
}
@keyframes fx-success-morph-button-spin {
to { transform: rotate(360deg); }
}
@keyframes fx-success-morph-button-draw {
from { stroke-dashoffset: 24; }
to { stroke-dashoffset: 0; }
}
@media (prefers-reduced-motion: reduce) {
.fx-success-morph-button,
.fx-success-morph-button__label,
.fx-success-morph-button__spinner,
.fx-success-morph-button__check {
transition: none;
animation: none;
}
.fx-success-morph-button.is-loading .fx-success-morph-button__spinner,
.fx-success-morph-button.is-success .fx-success-morph-button__check path {
animation: none;
}
}