Liquid✨ Premium

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".

CSSJSUX

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

Starting machine no. 4 — the connected laundry app of a student residence — Button Morph example 1

① Starting machine no. 4 — the connected laundry app of a student residence

WhenOn the "Machine 4 — 8 kg — 40 °C" card, the student taps "Start": the button shrinks into a circle while the machine answers, then turns green, and the end time of the cycle shows above.
WhyThe spinning circle covers the machine's real delay and the button ignores repeated taps: the student does not start two cycles out of impatience.
SettingsOriginal button (180 px, violet) centered under the card; data-busy and data-done set to the machine's messages; "done at 3:52 pm" message shown by the page during the .success phase.
Clocking in on the building site — a masonry company's time-tracking app — Button Morph example 2

② Clocking in on the building site — a masonry company's time-tracking app

WhenOn the crew leader's phone: "Les Tilleuls site — 7:32 am", the weather, today's crew and a big "Clock in" button.
WhyThe green check that replaces the button can be seen at arm's length, wearing gloves, in full sun; a plain "saved" text would go unnoticed.
SettingsResting width raised to 240 px through .btn-morph:not(.loading):not(.success), to keep the width animation; amber gradient #f59e0b → #d97706, amber ring; 320 px wide light screen.
Reporting a calf's birth — a cattle farmer's traceability app — Button Morph example 3

③ Reporting a calf's birth — a cattle farmer's traceability app

WhenThe form is filled in (mother's ID and name, date, sex, breed, weight); the farmer taps "Report the birth", and the assigned ear-tag number shows when the reply comes in.
WhyThe report has a legal deadline: the waiting-then-check sequence shows the database did answer, without opening a dialog.
Settings250 px resting button, green gradient #16a34a → #15803d and green ring; ear-tag number in a green badge at the top right during the .success phase.

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-busy and data-done to 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.

Chrome 60+✓ Full
Firefox 60+✓ Full
Safari 12+✓ Full
Edge 79+✓ Full
Mobile iOS 12+✓ Full (touch)
Android Chrome 60+✓ Full (touch)

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):

index.html — structure
<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 / propertyDefaultEffect
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

Replace the click body with: 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.
Because the button takes 0.5 s to go from pill to circle. If the rotation started on click, the still-wide pill, with its new border, would spin around: a bar sweeping across the page for a quarter second. The 0.5 s delay on fx0404-spin starts the rotation once the shape is round; to the eye, the wait stays continuous.
The animated width is 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.