← Back
Micro-interaction

Morphing Button

Button morphs through idle → loading spinner → success/error state with auto-reset.

idle → loading → success · CSS transitions · auto-reset

MorphingButton.tsx92 lines
"use client";

import { useState } from "react";

type State = "idle" | "loading" | "success" | "error";

function Btn({
  label,
  loadingLabel,
  successLabel,
  variant,
}: {
  label: string;
  loadingLabel: string;
  successLabel: string;
  variant: "primary" | "ghost" | "danger";
}) {
  const [state, setState] = useState<State>("idle");

  function click() {
    if (state !== "idle") return;
    setState("loading");
    const success = variant !== "danger";
    setTimeout(() => setState(success ? "success" : "error"), 1600);
    setTimeout(() => setState("idle"), 3200);
  }

  const base =
    "relative flex items-center justify-center gap-2 px-7 py-3 rounded-full font-semibold text-sm overflow-hidden transition-all duration-300 min-w-[160px]";

  const styles: Record<typeof variant, Record<State, string>> = {
    primary: {
      idle:    "bg-violet-600 text-white hover:bg-violet-700 shadow-lg shadow-violet-500/20",
      loading: "bg-violet-400 text-white cursor-wait",
      success: "bg-emerald-500 text-white shadow-lg shadow-emerald-500/20",
      error:   "bg-red-500 text-white shadow-lg shadow-red-500/20",
    },
    ghost: {
      idle:    "border-2 border-zinc-300 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:border-violet-400 hover:text-violet-600",
      loading: "border-2 border-zinc-200 dark:border-zinc-800 text-zinc-400 cursor-wait",
      success: "border-2 border-emerald-400 text-emerald-600 dark:text-emerald-400",
      error:   "border-2 border-red-400 text-red-600",
    },
    danger: {
      idle:    "bg-red-600 text-white hover:bg-red-700",
      loading: "bg-red-400 text-white cursor-wait",
      success: "bg-red-600 text-white",
      error:   "bg-zinc-500 text-white",
    },
  };

  return (
    <button onClick={click} className={`${base} ${styles[variant][state]}`}>
      {/* spinner */}
      {state === "loading" && (
        <span
          className="w-4 h-4 rounded-full border-2 border-current border-t-transparent"
          style={{ animation: "spin 0.7s linear infinite" }}
        />
      )}
      <span
        className="transition-all duration-300"
        style={{
          transform: state !== "idle" ? "none" : "none",
          opacity: 1,
        }}
      >
        {state === "idle"    ? label
         : state === "loading" ? loadingLabel
         : state === "success" ? successLabel
         : "✕ Failed"}
      </span>

      <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
    </button>
  );
}

export default function MorphingButton() {
  return (
    <div className="flex flex-col items-center gap-6 py-12 w-full">
      <Btn label="Deploy to production" loadingLabel="Deploying…" successLabel="✓ Deployed!" variant="primary" />
      <Btn label="Save changes"         loadingLabel="Saving…"    successLabel="✓ Saved!"    variant="ghost"   />
      <Btn label="Delete account"       loadingLabel="Deleting…"  successLabel="Deleted"     variant="danger"  />

      <p className="text-[10px] font-mono text-zinc-400 dark:text-zinc-600 mt-2">
        idle → loading → success · CSS transitions · auto-reset
      </p>
    </div>
  );
}

More in Micro-interaction