Button Morph
One button that tells three states: on click, the 180 px pill shrinks into a 52 px circle whose border spins while waiting, then it turns into a green dot where a check mark pops in, before becoming a button again. The change of shape is the status indicator; a second click during the sequence is ignored, and screen readers hear "Sending…" then "Sent".
Updated
This effect is part of Effect.Labs — 840 vanilla effects, some free, some premium. Liquid has 31 effects, including 6 free. Explore the category →
3 usage examples



How it works
The .btn-morph button (at least 180 px wide, 52 px tall, violet gradient) holds its text and an SVG check mark scaled to zero. On click, the script adds .loading: min-width and width go to 52 px, the corners to 50%, the background turns transparent and a 3 px border appears, violet on top, pale elsewhere. After 1.5 s, .loading gives way to .success: a green dot (#10b981 → #059669), with a check mark growing from 0 to 1 with a bounce. Another 1.5 s, and the button takes its shape back. A running variable ignores clicks during those 3 s: no way to trigger the same action twice out of impatience.
The shrink runs through a single transition, .5s cubic-bezier(.68, -.55, .27, 1.55), which first swells the button slightly before it tightens and slightly overshoots its final size. min-width carries the animation: an auto width does not interpolate, whereas a minimum width from 180 px to 52 px does. The ring's rotation (fx0404-spin, one turn in 0.8 s) waits 0.5 s, the end of the shrink: without that delay, the still-wide pill would spin like a propeller during the first quarter second.
The state is not only visual. While waiting, the button has aria-busy="true", and an aria-live="polite" area, .btn-morph-status, invisible on screen, receives the data-busy text ("Sending…") then the data-done text ("Sent"); it is emptied on the way back, so the announcement starts again on the next click. The button has type="button": inside a form, it does not submit it by itself. The two setTimeout calls simulate a server response: replace them with your request and switch to .success when it answers.
Accessibility
- Screen readers: measured in Chromium, Firefox and WebKit, the announced area holds "Sending…" at 0.4 s, "Sent" at 1.7 s, then nothing after the reset. Adapt
data-busyanddata-doneto your action ("Starting the machine…", "Machine started"). - Keyboard: it is a native
<button>, reached with Tab and activated with Enter or Space, with a light violet focus ring (:focus-visible). During the sequence, focus stays on the button; repeated presses are ignored. - Reduced motion: under
prefers-reduced-motion: reduce, the button takes its circle shape at once (52 px at the first measure, 150 ms), the ring stays still instead of spinning, the check mark and colors fade in over 0.3 s, and the button no longer lifts on hover. - Color: success is not shown by green alone, the check mark and the announcement confirm it. If the action can fail, add an error state with text, not just a red color.
Browser compatibility
CSS (transitions, @keyframes) + 1.2 KB of JavaScript, no dependencies. Measured in Chromium 151, Firefox 153 and WebKit 26: same sequence (circle at 0.4 s, green dot around 1.5 s, button at 3 s), same announcements, same stopped rotation under reduced motion. The requested font is Inter: if your page does not load it, the text shows in the system sans-serif font.
Without JavaScript, it is a normal button that never changes shape: wire your action to a real form so it still works. Without CSS transitions, the three states follow each other without animation.
The code
HTML structure to paste into your page (the CSS + JS come with your purchase):
<div class="fx-0404-root">
<button type="button" class="btn-morph" data-busy="Sending…" data-done="Sent">
<span class="btn-text">Submit</span>
<svg class="check-icon" viewBox="0 0 24 24" aria-hidden="true">…</svg>
</button>
<span class="btn-morph-status" aria-live="polite"></span>
</div>
One-time payment, no subscription — the code is yours forever
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
State durations (JS — the two setTimeout(…, 1500) calls) |
1.5 s waiting, 1.5 s success | With a real request, remove the first delay and switch to .success inside the .then(). Keep at least 1 s of success so the check mark is seen. |
Announced messages (HTML — data-busy / data-done) |
"Sending…" / "Sent" | Describe the real action: "Clocking in…" / "Arrival recorded". These texts are not displayed, they are read by screen readers. |
Resting width (CSS — min-width of .btn-morph) |
180 px | For a long label, widen it with .btn-morph:not(.loading):not(.success) { min-width: 240px }: the animation starts from that width. Text wider than min-width makes the button jump to that value before it tightens. |
Colors (CSS — background of .btn-morph and .success, border-color of .loading) |
violet #a855f7 → #7c3aed, green #10b981 → #059669 |
Match the ring to the button color (first color solid, the other three at 30%) so the change reads as the same object. |
Ring speed (CSS — animation: .8s linear .5s infinite … fx0404-spin) |
one turn in 0.8 s, after 0.5 s | 1.2 s gives a calmer wait. Keep the delay equal to the transition duration, or the pill spins before it is round. |
FAQ
btn.classList.add('loading'); fetch(url, { method: 'POST' }).then(function (r) { btn.classList.remove('loading'); btn.classList.add(r.ok ? 'success' : 'error'); });, then remove .success after 1.5 s as the code does. Keep the running variable and the data-busy / data-done announcements. An .error class (red background and a "Try again" text) has to be added to the CSS if the action can fail.fx0404-spin starts the rotation once the shape is round; to the eye, the wait stays continuous.min-width, from 180 px to 52 px. If your text needs 220 px, the button is 220 px at rest, but on click it first jumps to 180 px and then tightens: a small jolt. Give the button a resting minimum width at least as wide as its text, with :not(.loading):not(.success) so the other two states are unaffected, as in the building site and cattle farm examples.