Success Morph Button
Original · freeA submit button that morphs into a checkmark on success.
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"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
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 quoteSimilar components
Browse all →
Shimmer Button
MITA dark pill button with a continuous conic-gradient light shimmer sweeping around its border.

Rainbow Gradient Button
MITA button wrapped in an animated multi-colour gradient border with a matching blurred underglow.

Interactive Hover Button
MITA pill button whose dot expands to fill the surface and reveals an arrow label on hover.

Pulsating Button
MITA solid button that emits a soft, continuously pulsing glow ring to draw the eye to the primary action.

Shiny Button
MITA frosted-glass button with a spring-animated light sweeping across its text and border.

Solid Set Button
OriginalA set of solid buttons: primary, secondary, success and danger, in three sizes.

Outline Set Button
OriginalOutline button variants across colours and sizes with a hover fill.

Ghost Set Button
OriginalGhost and text buttons with subtle hover backgrounds.

Soft Set Button
OriginalSoft, tinted buttons across semantic colours.

Gradient Set Button
OriginalGradient-filled buttons with a hover shift.
Icon Leading Button
OriginalButtons with leading and trailing icons.
Icon Only Button
OriginalSquare and circular icon-only buttons with tooltips and aria-labels.

