Web InnoventixFreeCode

Success Morph Button

Original · free

A submit button that morphs into a checkmark on success.

byWeb InnoventixReact + Tailwind
btnsuccessmorphbuttons
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/btn-success-morph.json
btn-success-morph.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

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

type Tone = "indigo" | "emerald" | "violet";

const TONES: Record<
  Tone,
  { base: string; ring: string; glow: string }
> = {
  indigo: {
    base:
      "bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:active:bg-indigo-600",
    ring: "focus-visible:ring-indigo-500 dark:focus-visible:ring-indigo-400",
    glow: "shadow-indigo-600/30 dark:shadow-indigo-500/30",
  },
  emerald: {
    base:
      "bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 dark:bg-emerald-500 dark:hover:bg-emerald-400 dark:active:bg-emerald-600",
    ring: "focus-visible:ring-emerald-500 dark:focus-visible:ring-emerald-400",
    glow: "shadow-emerald-600/30 dark:shadow-emerald-500/30",
  },
  violet: {
    base:
      "bg-violet-600 hover:bg-violet-500 active:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-400 dark:active:bg-violet-600",
    ring: "focus-visible:ring-violet-500 dark:focus-visible:ring-violet-400",
    glow: "shadow-violet-600/30 dark:shadow-violet-500/30",
  },
};

const SIZES = {
  sm: { pad: "h-9 px-4 text-sm", gap: "gap-1.5", icon: 15, spin: "h-4 w-4" },
  md: { pad: "h-11 px-6 text-sm", gap: "gap-2", icon: 17, spin: "h-[18px] w-[18px]" },
  lg: { pad: "h-13 px-8 text-base", gap: "gap-2.5", icon: 19, spin: "h-5 w-5" },
} as const;

type Size = keyof typeof SIZES;

function Spinner({ className }: { className: string }) {
  return (
    <svg
      className={`${className} bsm-spin`}
      viewBox="0 0 24 24"
      fill="none"
      aria-hidden="true"
    >
      <circle
        cx="12"
        cy="12"
        r="9"
        stroke="currentColor"
        strokeWidth="2.5"
        className="opacity-25"
      />
      <path
        d="M21 12a9 9 0 0 0-9-9"
        stroke="currentColor"
        strokeWidth="2.5"
        strokeLinecap="round"
      />
    </svg>
  );
}

function Check({ size, animate }: { size: number; animate: boolean }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="3"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path
        d="M20 6 9 17l-5-5"
        pathLength={1}
        className={animate ? "bsm-draw" : undefined}
      />
    </svg>
  );
}

function Cross({ size }: { size: number }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="3"
      strokeLinecap="round"
      aria-hidden="true"
    >
      <path d="M18 6 6 18M6 6l12 12" />
    </svg>
  );
}

function MorphButton({
  tone = "indigo",
  size = "md",
  idleLabel = "Save changes",
  loadingLabel = "Saving",
  successLabel = "Saved",
  shouldFail = false,
}: {
  tone?: Tone;
  size?: Size;
  idleLabel?: string;
  loadingLabel?: string;
  successLabel?: string;
  shouldFail?: boolean;
}) {
  const reduce = useReducedMotion();
  const [status, setStatus] = useState<Status>("idle");
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  const t = TONES[tone];
  const s = SIZES[size];

  useEffect(() => {
    const list = timers.current;
    return () => {
      list.forEach(clearTimeout);
    };
  }, []);

  const run = useCallback(() => {
    if (status === "loading" || status === "success") return;
    setStatus("loading");
    const outcome = shouldFail ? "error" : "success";
    timers.current.push(
      setTimeout(() => {
        setStatus(outcome);
        if (outcome === "success") {
          timers.current.push(
            setTimeout(() => setStatus("idle"), 2200)
          );
        } else {
          timers.current.push(
            setTimeout(() => setStatus("idle"), 1800)
          );
        }
      }, 1400)
    );
  }, [status, shouldFail]);

  const isSuccess = status === "success";
  const isError = status === "error";
  const isLoading = status === "loading";

  const surface = isSuccess
    ? "bg-emerald-600 hover:bg-emerald-600 dark:bg-emerald-500 dark:hover:bg-emerald-500 shadow-emerald-600/30 dark:shadow-emerald-500/30"
    : isError
    ? "bg-rose-600 hover:bg-rose-600 dark:bg-rose-500 dark:hover:bg-rose-500 shadow-rose-600/30 dark:shadow-rose-500/30"
    : `${t.base} ${t.glow}`;

  const ring = isSuccess
    ? "focus-visible:ring-emerald-500 dark:focus-visible:ring-emerald-400"
    : isError
    ? "focus-visible:ring-rose-500 dark:focus-visible:ring-rose-400"
    : t.ring;

  const label = isLoading
    ? loadingLabel
    : isSuccess
    ? successLabel
    : isError
    ? "Try again"
    : idleLabel;

  const spring = reduce
    ? { duration: 0 }
    : { type: "spring" as const, stiffness: 520, damping: 30 };

  return (
    <motion.button
      type="button"
      onClick={run}
      disabled={isLoading || isSuccess}
      aria-live="polite"
      aria-label={isLoading ? loadingLabel : undefined}
      animate={
        reduce
          ? undefined
          : isError
          ? { x: [0, -5, 5, -4, 4, 0] }
          : { x: 0 }
      }
      transition={{ duration: isError ? 0.4 : 0.2 }}
      layout
      className={`relative inline-flex items-center justify-center overflow-hidden rounded-xl font-semibold text-white shadow-lg outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-default dark:focus-visible:ring-offset-slate-950 ${s.pad} ${s.gap} ${surface} ${ring}`}
      style={{ borderRadius: isSuccess ? 999 : 12 }}
    >
      <AnimatePresence mode="popLayout" initial={false}>
        {isLoading && (
          <motion.span
            key="loading"
            layout
            className={`inline-flex items-center ${s.gap}`}
            initial={reduce ? false : { opacity: 0, y: 6 }}
            animate={{ opacity: 1, y: 0 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6 }}
            transition={spring}
          >
            <Spinner className={s.spin} />
            <motion.span layout>{label}</motion.span>
            <span className="bsm-dots" aria-hidden="true">
              <i />
              <i />
              <i />
            </span>
          </motion.span>
        )}

        {isSuccess && (
          <motion.span
            key="success"
            layout
            className={`inline-flex items-center ${s.gap}`}
            initial={reduce ? false : { opacity: 0, scale: 0.6 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
            transition={spring}
          >
            <span className="grid place-items-center rounded-full bg-white/20 p-1">
              <Check size={s.icon} animate={!reduce} />
            </span>
            <motion.span layout>{label}</motion.span>
          </motion.span>
        )}

        {isError && (
          <motion.span
            key="error"
            layout
            className={`inline-flex items-center ${s.gap}`}
            initial={reduce ? false : { opacity: 0, scale: 0.7 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.7 }}
            transition={spring}
          >
            <Cross size={s.icon} />
            <motion.span layout>{label}</motion.span>
          </motion.span>
        )}

        {status === "idle" && (
          <motion.span
            key="idle"
            layout
            className={`inline-flex items-center ${s.gap}`}
            initial={reduce ? false : { opacity: 0, y: 6 }}
            animate={{ opacity: 1, y: 0 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6 }}
            transition={spring}
          >
            <motion.span layout>{label}</motion.span>
          </motion.span>
        )}
      </AnimatePresence>
    </motion.button>
  );
}

export default function BtnSuccessMorph() {
  return (
    <section className="relative w-full bg-white px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-24">
      <style>{`
        @keyframes bsm-spin { to { transform: rotate(360deg); } }
        .bsm-spin { animation: bsm-spin 0.7s linear infinite; }

        @keyframes bsm-draw {
          from { stroke-dashoffset: 1; }
          to { stroke-dashoffset: 0; }
        }
        .bsm-draw {
          stroke-dasharray: 1;
          stroke-dashoffset: 1;
          animation: bsm-draw 0.42s cubic-bezier(0.65, 0, 0.35, 1) 0.06s forwards;
        }

        .bsm-dots { display: inline-flex; gap: 3px; align-items: flex-end; }
        .bsm-dots i {
          width: 3px; height: 3px; border-radius: 999px;
          background: currentColor;
          animation: bsm-bounce 1s ease-in-out infinite;
        }
        .bsm-dots i:nth-child(2) { animation-delay: 0.15s; }
        .bsm-dots i:nth-child(3) { animation-delay: 0.3s; }
        @keyframes bsm-bounce {
          0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
          40% { transform: translateY(-3px); opacity: 1; }
        }

        @media (prefers-reduced-motion: reduce) {
          .bsm-spin, .bsm-draw, .bsm-dots i { animation: none !important; }
          .bsm-draw { stroke-dashoffset: 0; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <header className="mb-12 text-center">
          <span className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Interactive
          </span>
          <h2 className="mt-4 text-2xl font-bold tracking-tight sm:text-3xl">
            Success-morph submit button
          </h2>
          <p className="mx-auto mt-3 max-w-md text-sm text-slate-600 dark:text-slate-400">
            Click a button. It shows a live spinner, then morphs into a
            checkmark on success or shakes on failure.
          </p>
        </header>

        <div className="grid gap-10">
          <div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-8 dark:border-slate-800 dark:bg-slate-900/40">
            <p className="mb-5 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-500">
              Tones
            </p>
            <div className="flex flex-wrap items-center gap-4">
              <MorphButton tone="indigo" idleLabel="Save changes" successLabel="Saved" />
              <MorphButton
                tone="violet"
                idleLabel="Publish post"
                loadingLabel="Publishing"
                successLabel="Published"
              />
              <MorphButton
                tone="emerald"
                idleLabel="Confirm order"
                loadingLabel="Processing"
                successLabel="Confirmed"
              />
            </div>
          </div>

          <div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-8 dark:border-slate-800 dark:bg-slate-900/40">
            <p className="mb-5 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-500">
              Sizes
            </p>
            <div className="flex flex-wrap items-center gap-4">
              <MorphButton size="sm" idleLabel="Subscribe" successLabel="Subscribed" />
              <MorphButton size="md" idleLabel="Subscribe" successLabel="Subscribed" />
              <MorphButton size="lg" idleLabel="Subscribe" successLabel="Subscribed" />
            </div>
          </div>

          <div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-8 dark:border-slate-800 dark:bg-slate-900/40">
            <p className="mb-5 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-500">
              Failure state
            </p>
            <div className="flex flex-wrap items-center gap-4">
              <MorphButton
                tone="indigo"
                shouldFail
                idleLabel="Charge $49.00"
                loadingLabel="Charging"
              />
              <span className="text-sm text-slate-500 dark:text-slate-400">
                This one rejects, shakes, then resets.
              </span>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →