SVG Morph Button
Button with SVG path morph animation. Supports custom paths for idle, hover, loading, success.
Last updated Mar 5, 2026
Component
"use client";
import { interpolate } from "flubber";
import gsap from "gsap";
import {
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
const DEFAULT_PATHS = {
check: "M5 12 L10 17 L19 6 L19 6 L5 12 Z",
circle:
"M12 4 A8 8 0 0 1 20 12 A8 8 0 0 1 12 20 A8 8 0 0 1 4 12 A8 8 0 0 1 12 4",
plus: "M12 2 L14 2 L14 10 L22 10 L22 12 L14 12 L14 22 L12 22 L12 12 L4 12 L4 10 L12 10 Z",
x: "M12 4 L20 12 L12 20 L4 12 Z",
} as const;
type MorphState = "idle" | "hover" | "loading" | "success";
interface SvgMorphButtonProps {
children?: React.ReactNode;
className?: string;
paths?: Partial<Record<MorphState, string>>;
customPaths?: Record<string, string>;
morphDuration?: number;
idleLabel?: string;
loadingLabel?: string;
successLabel?: string;
onIdle?: () => void;
onHover?: () => void;
onLoading?: () => void;
onSuccess?: () => void;
onClick?: () => void;
}
export function SvgMorphButton({
children,
className,
paths: pathsProp,
customPaths,
morphDuration = 0.35,
idleLabel = "Click me",
loadingLabel = "Loading...",
successLabel = "Done!",
onIdle,
onHover,
onLoading,
onSuccess,
onClick,
}: SvgMorphButtonProps) {
const [state, setState] = useState<MorphState>("idle");
const pathRef = useRef<SVGPathElement>(null);
const prevStateRef = useRef<MorphState>("idle");
const progressRef = useRef({ v: 0 });
const pathsRef = useRef({
...DEFAULT_PATHS,
...customPaths,
...pathsProp,
});
pathsRef.current = {
...DEFAULT_PATHS,
...customPaths,
...pathsProp,
};
const getPath = useCallback((s: MorphState) => {
const p = pathsRef.current;
return (
p[s] ??
(s === "loading"
? p.circle
: s === "hover"
? p.x
: s === "success"
? p.check
: p.plus)
);
}, []);
useEffect(() => {
const pathEl = pathRef.current;
if (!pathEl) {
return;
}
const from = prevStateRef.current;
const to = state;
const fromPath = getPath(from);
const toPath = getPath(to);
if (!(fromPath && toPath) || from === to) {
return;
}
prevStateRef.current = to;
let interp: (t: number) => string;
try {
interp = interpolate(fromPath, toPath);
} catch {
pathEl.setAttribute("d", toPath);
return;
}
progressRef.current.v = 0;
const tween = gsap.to(progressRef.current, {
duration: morphDuration,
ease: "power2.inOut",
onUpdate: () => {
pathEl.setAttribute(
"d",
interp(progressRef.current.v),
);
},
v: 1,
});
return () => {
tween.kill();
};
}, [state, morphDuration, getPath]);
const handleClick = () => {
if (state === "loading" || state === "success") {
return;
}
setState("loading");
onLoading?.();
onClick?.();
setTimeout(() => {
setState("success");
onSuccess?.();
}, 1500);
setTimeout(() => {
setState("idle");
onIdle?.();
}, 2500);
};
return (
<button
type="button"
onMouseEnter={() => {
if (state !== "loading" && state !== "success") {
setState("hover");
onHover?.();
}
}}
onMouseLeave={() => {
if (state !== "loading" && state !== "success") {
setState("idle");
onIdle?.();
}
}}
onClick={handleClick}
className={cn(
"group flex items-center gap-3 rounded-xl border border-white/20 bg-white/5 px-6 py-3 transition-colors hover:bg-white/10",
className,
)}
>
<span className="relative flex size-6 items-center justify-center">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={
state === "loading" ? "animate-spin" : ""
}
aria-hidden={true}
>
<path
ref={pathRef}
d={getPath("idle")}
fill="currentColor"
stroke="none"
/>
</svg>
</span>
{children ?? (
<span>
{state === "success"
? successLabel
: state === "loading"
? loadingLabel
: idleLabel}
</span>
)}
</button>
);
}Installation
pnpm dlx shadcn@latest add "https://pulkitxm.com/components/svg-morph-button.json"1. Install dependencies
pnpm add flubber gsap2. Copy the component file
"use client";
import { interpolate } from "flubber";
import gsap from "gsap";
import {
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
const DEFAULT_PATHS = {
check: "M5 12 L10 17 L19 6 L19 6 L5 12 Z",
circle:
"M12 4 A8 8 0 0 1 20 12 A8 8 0 0 1 12 20 A8 8 0 0 1 4 12 A8 8 0 0 1 12 4",
plus: "M12 2 L14 2 L14 10 L22 10 L22 12 L14 12 L14 22 L12 22 L12 12 L4 12 L4 10 L12 10 Z",
x: "M12 4 L20 12 L12 20 L4 12 Z",
} as const;
type MorphState = "idle" | "hover" | "loading" | "success";
interface SvgMorphButtonProps {
children?: React.ReactNode;
className?: string;
paths?: Partial<Record<MorphState, string>>;
customPaths?: Record<string, string>;
morphDuration?: number;
idleLabel?: string;
loadingLabel?: string;
successLabel?: string;
onIdle?: () => void;
onHover?: () => void;
onLoading?: () => void;
onSuccess?: () => void;
onClick?: () => void;
}
export function SvgMorphButton({
children,
className,
paths: pathsProp,
customPaths,
morphDuration = 0.35,
idleLabel = "Click me",
loadingLabel = "Loading...",
successLabel = "Done!",
onIdle,
onHover,
onLoading,
onSuccess,
onClick,
}: SvgMorphButtonProps) {
const [state, setState] = useState<MorphState>("idle");
const pathRef = useRef<SVGPathElement>(null);
const prevStateRef = useRef<MorphState>("idle");
const progressRef = useRef({ v: 0 });
const pathsRef = useRef({
...DEFAULT_PATHS,
...customPaths,
...pathsProp,
});
pathsRef.current = {
...DEFAULT_PATHS,
...customPaths,
...pathsProp,
};
const getPath = useCallback((s: MorphState) => {
const p = pathsRef.current;
return (
p[s] ??
(s === "loading"
? p.circle
: s === "hover"
? p.x
: s === "success"
? p.check
: p.plus)
);
}, []);
useEffect(() => {
const pathEl = pathRef.current;
if (!pathEl) {
return;
}
const from = prevStateRef.current;
const to = state;
const fromPath = getPath(from);
const toPath = getPath(to);
if (!(fromPath && toPath) || from === to) {
return;
}
prevStateRef.current = to;
let interp: (t: number) => string;
try {
interp = interpolate(fromPath, toPath);
} catch {
pathEl.setAttribute("d", toPath);
return;
}
progressRef.current.v = 0;
const tween = gsap.to(progressRef.current, {
duration: morphDuration,
ease: "power2.inOut",
onUpdate: () => {
pathEl.setAttribute(
"d",
interp(progressRef.current.v),
);
},
v: 1,
});
return () => {
tween.kill();
};
}, [state, morphDuration, getPath]);
const handleClick = () => {
if (state === "loading" || state === "success") {
return;
}
setState("loading");
onLoading?.();
onClick?.();
setTimeout(() => {
setState("success");
onSuccess?.();
}, 1500);
setTimeout(() => {
setState("idle");
onIdle?.();
}, 2500);
};
return (
<button
type="button"
onMouseEnter={() => {
if (state !== "loading" && state !== "success") {
setState("hover");
onHover?.();
}
}}
onMouseLeave={() => {
if (state !== "loading" && state !== "success") {
setState("idle");
onIdle?.();
}
}}
onClick={handleClick}
className={cn(
"group flex items-center gap-3 rounded-xl border border-white/20 bg-white/5 px-6 py-3 transition-colors hover:bg-white/10",
className,
)}
>
<span className="relative flex size-6 items-center justify-center">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={
state === "loading" ? "animate-spin" : ""
}
aria-hidden={true}
>
<path
ref={pathRef}
d={getPath("idle")}
fill="currentColor"
stroke="none"
/>
</svg>
</span>
{children ?? (
<span>
{state === "success"
? successLabel
: state === "loading"
? loadingLabel
: idleLabel}
</span>
)}
</button>
);
}3. Import and use
import { SvgMorphButton } from "@/components/svg-morph-button";
<SvgMorphButton>Submit</SvgMorphButton>;Usage
Import
Add the SvgMorphButton import.
import { SvgMorphButton } from "@/components/svg-morph-button";Use
Use with children. Click to see loading → success morph.
<SvgMorphButton>Submit</SvgMorphButton>;Guidelines
- Default paths: plus (idle), x (hover), circle (loading), check (success).
- Pass customPaths to override. Keys: idle, hover, loading, success.
- morphDuration controls animation speed.
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | Additional CSS classes. |
| customPaths | Partial<Record<string, string>> | — | Custom paths for idle, hover, loading, success. |
| morphDuration | number | 0.35 | Morph animation duration in seconds. |
| idleLabel | string | "Click me" | Label when idle. |
| loadingLabel | string | "Loading..." | Label during loading state. |
| successLabel | string | "Done!" | Label when success. |
Examples
Basic
Default button with plus → loading → check morph.
import { SvgMorphButton } from "@/components/svg-morph-button";
<SvgMorphButton>
<span>Submit</span>
</SvgMorphButton>;Custom paths
Custom SVG paths for each state.
import { SvgMorphButton } from "@/components/svg-morph-button";
const paths = {
idle: "M12 2 L14 2 L14 10 L22 10 L22 12 L14 12 L14 22 L12 22 Z",
hover: "M12 4 L20 12 L12 20 L4 12 Z",
loading: "M12 4 A8 8 0 0 1 20 12 A8 8 0 0 1 12 20 Z",
success: "M5 12 L10 17 L19 6 Z",
};
<SvgMorphButton customPaths={paths}>
<span>Custom</span>
</SvgMorphButton>;