Buttons
MorphAction
The pill shrinks into a spinner while the action runs, draws a tick when it is done, then turns back into the label.
Download
- React
- Tailwind CSS v4
- motion
components/ui/morph-action.tsx
"use client";
import { useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type MorphActionProps = {
children: ReactNode;
className?: string;
/** Runs on click. Return a promise and the button waits for it. */
onClick?: () => unknown;
/** Read out to screen readers when the action finishes. */
doneLabel?: string;
/** Seconds the tick stays before the label comes back. */
doneFor?: number;
};
type State = "idle" | "busy" | "done";
/* MorphAction: the pill shrinks into a spinner while the action runs, shows a tick when it is done, then turns back. */
export function MorphAction({
children,
className = "bg-[#a78bfa] text-[15px] font-medium text-[#1a1530]",
onClick,
doneLabel = "Done",
doneFor = 1.6,
}: MorphActionProps) {
const reduce = useReducedMotion();
const [state, setState] = useState<State>("idle");
const run = async () => {
if (state !== "idle") return;
setState("busy");
try {
await onClick?.();
setState("done");
setTimeout(() => setState("idle"), doneFor * 1000);
} catch {
setState("idle");
}
};
const fade = {
initial: { opacity: 0, scale: 0.6 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.6 },
transition: { duration: reduce ? 0 : 0.15 },
};
return (
<motion.button
type="button"
layout
onClick={run}
aria-busy={state === "busy"}
className={`grid h-14 place-items-center overflow-hidden ${state === "idle" ? "px-9" : "w-14"} ${className}`}
style={{ borderRadius: 999 }}
transition={reduce ? { duration: 0 } : { type: "spring", visualDuration: 0.35, bounce: 0.25 }}
>
<AnimatePresence mode="wait" initial={false}>
{state === "idle" && (
<motion.span key="idle" {...fade} className="whitespace-nowrap">
{children}
</motion.span>
)}
{state === "busy" && (
<motion.span key="busy" {...fade} aria-hidden>
<motion.svg
viewBox="0 0 24 24"
className="size-6"
animate={reduce ? undefined : { rotate: 360 }}
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
>
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="2.5" strokeDasharray="40 60" strokeLinecap="round" />
</motion.svg>
</motion.span>
)}
{state === "done" && (
<motion.span key="done" {...fade} aria-hidden>
<svg viewBox="0 0 24 24" className="size-6">
<motion.path
d="M5 12.5l4.5 4.5L19 7.5"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduce ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
/>
</svg>
</motion.span>
)}
</AnimatePresence>
<span className="sr-only" aria-live="polite">
{state === "busy" ? "Working" : state === "done" ? doneLabel : ""}
</span>
</motion.button>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/morph-action.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/morph-action-demo.tsx
"use client"; import { MorphAction } from "@/components/ui/morph-action"; // Stands in for a real request. const save = () => new Promise((done) => setTimeout(done, 1400)); export default function MorphActionExample() { return ( <div className="grid h-full place-items-center bg-[radial-gradient(circle_at_50%_60%,#1d1a33,#0d0c14_70%)]"> <MorphAction onClick={save}>Save changes</MorphAction> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Button label. |
| onClick | () => unknown | — | Runs on click. Return a promise and the button waits for it; a rejected promise goes straight back to the label. |
| doneLabel | string | "Done" | Read out to screen readers when finished. |
| doneFor | number | 1.6 | Seconds the tick stays. |
| className | string | violet pill (colours and type only; the shape is the morph) | Replaces the default look. |