Swish UI
All componentsButtons and controls Free

Stateful save button

The label turns into a spinner, then a check with Saved, while the button smoothly resizes to fit each state.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import styles from "./demo.module.css";

type State = "idle" | "saving" | "saved";
const LABEL: Record<State, string> = { idle: "Save changes", saving: "Saving", saved: "Saved" };
const PADDING = 26;

export default function Demo() {
  const button = useRef<HTMLButtonElement>(null);
  const inner = useRef<HTMLSpanElement>(null);
  const [state, setState] = useState<State>("idle");

  // The button gets an explicit width so the change between labels can transition.
  useLayoutEffect(() => {
    if (button.current && inner.current) button.current.style.width = `${inner.current.offsetWidth + PADDING * 2}px`;
  }, [state]);

  useEffect(() => {
    if (state === "idle") return;
    const timer = setTimeout(() => setState(state === "saving" ? "saved" : "idle"), state === "saving" ? 1400 : 1800);
    return () => clearTimeout(timer);
  }, [state]);

  const busy = state !== "idle";

  return (
    <div className={styles.root}>
      <div className={styles.stack}>
        <button
          ref={button}
          type="button"
          className={`${styles.button} ${styles[state]}`}
          aria-disabled={busy}
          onClick={() => !busy && setState("saving")}
        >
          <span ref={inner} key={state} className={styles.inner}>
            {state === "saving" && <span className={styles.spinner} aria-hidden="true" />}
            {state === "saved" && (
              <svg className={styles.check} viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
                <path d="M3.5 8.5 6.5 11.5 12.5 4.5" pathLength="1" />
              </svg>
            )}
            {LABEL[state]}
          </span>
        </button>
        <p className={styles.note}>Profile, 3 fields edited</p>
        <span className={styles.sr} aria-live="polite">{busy ? LABEL[state] : ""}</span>
      </div>
    </div>
  );
}

demo.module.css

.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.stack { display: grid; justify-items: center; gap: 12px; }
.button {
  display: inline-grid; place-items: center; overflow: hidden;
  min-height: 52px; padding: 0; border: 0; border-radius: 999px;
  background: var(--ink); color: var(--white); font-size: 15px; font-weight: 600; cursor: pointer;
  transition: width .45s var(--out), background-color .3s, transform .18s var(--out);
}
.button:active { transform: scale(.97); }
.saving { background: var(--ink-hover); cursor: progress; }
.saved { background: var(--accent-text); cursor: default; }
.inner { display: inline-flex; align-items: center; gap: 10px; white-space: nowrap; animation: enter .35s var(--out); }
@keyframes enter { from { opacity: 0; transform: translateY(6px); } }
.spinner {
  width: 16px; height: 16px; border-radius: 50%; flex: none;
  border: 2px solid color-mix(in srgb, var(--white) 30%, transparent); border-top-color: var(--white);
  animation: spin .7s linear infinite;
}
@keyframes spin { to { rotate: 1turn; } }
.check { flex: none; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.check path { stroke-dasharray: 1; animation: draw .4s var(--out) .1s both; }
@keyframes draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
.note { font-size: 13px; color: var(--muted); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
  .inner, .check path { animation: none; }
  .spinner { animation: none; border-color: var(--white); border-right-color: transparent; }
}

Use it for

  • Save, send and submit actions that take a second or two
  • Inline forms where a separate toast would be too far from the click

Avoid

  • Actions that finish instantly, where the spinner only adds delay
  • Long running jobs that need a real progress bar

How it works

The button holds one of three states. After every state change a layout effect measures the inner content and writes an explicit pixel width, so the width transition can run between labels. The inner span is keyed by state, so each new label fades up on mount. Timers move saving to saved and saved back to idle and are cleared in the effect cleanup.

Accessibility

The button stays focusable while busy (aria-disabled instead of disabled) and ignores clicks. A polite live region announces Saving and Saved. Reduced motion stops the spinner rotation and the check draw, showing their static forms.

Found on