Skip to content
Menu

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.

Scene

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

HTML — code.html
<!-- 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>
Alpine — alpine.js
// 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)
        },
    }
}
CSS — styles.css
/* 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;
    }
}