Overview

One button for an action's whole lifecycle. Its width springs to fit each label, so it grows and shrinks instead of jumping. Labels rise out of a blur as the old one leaves, the spinner swaps for a check or cross drawn stroke by stroke, and the colour shifts to green or red. A success sends out a soft ring; an error gives the button a small head-shake. Status changes are announced to screen readers without changing the button's name.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/morph-button.json

Install dependencies:

$ pnpm add motion lucide-react

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/morph-button.tsx
tsx
"use client";
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";import {  AnimatePresence,  motion,  useAnimate,  useReducedMotion,} from "motion/react";import { cn } from "@/lib/utils";
export type MorphButtonStatus = "idle" | "loading" | "success" | "error";
export type MorphButtonProps = {  status?: MorphButtonStatus;  onClick?: () => void;  idleLabel?: string;  loadingLabel?: string;  successLabel?: string;  errorLabel?: string;  /** Optional icon shown before the idle label. */  idleIcon?: React.ReactNode;  /** Auto-return to idle after success/error (ms). 0 = never. */  resetAfterMs?: number;  onReset?: () => void;  disabled?: boolean;  className?: string;  type?: "button" | "submit" | "reset";};
const EASE_OUT = [0.23, 1, 0.32, 1] as const;const WIDTH_SPRING = { type: "spring" as const, bounce: 0.18, duration: 0.5 };const PAD_X = 18;
function Spinner() {  return (    <motion.svg      viewBox="0 0 16 16"      className="size-full"      animate={{ rotate: 360 }}      transition={{ repeat: Infinity, ease: "linear", duration: 0.8 }}    >      <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />      <path d="M14 8a6 6 0 0 0-6-6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />    </motion.svg>  );}
function DrawnGlyph({ kind, reduce }: { kind: "check" | "cross"; reduce: boolean }) {  const draw = (delay = 0) => ({    initial: { pathLength: reduce ? 1 : 0 },    animate: { pathLength: 1 },    transition: { duration: 0.32, ease: EASE_OUT, delay },  });  return (    <svg viewBox="0 0 16 16" className="size-full" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">      {kind === "check" ? (        <motion.path d="M3.5 8.5l3 3 6-7" {...draw(0.06)} />      ) : (        <>          <motion.path d="M4.5 4.5l7 7" {...draw(0.04)} />          <motion.path d="M11.5 4.5l-7 7" {...draw(0.14)} />        </>      )}    </svg>  );}
export function MorphButton({  status = "idle",  onClick,  idleLabel = "Send invite",  loadingLabel = "Sending",  successLabel = "Sent",  errorLabel = "Failed",  idleIcon,  resetAfterMs = 0,  onReset,  disabled,  className,  type = "button",}: MorphButtonProps) {  const reduce = !!useReducedMotion();  const [scope, animate] = useAnimate<HTMLButtonElement>();  const contentRef = useRef<HTMLSpanElement>(null);  const [width, setWidth] = useState<number | null>(null);  const [burst, setBurst] = useState(0);  const prevStatus = useRef(status);
  // Keep the latest callback out of the timer effect so an inline  // `onReset` does not restart the countdown on every parent render.  const onResetRef = useRef(onReset);  useEffect(() => {    onResetRef.current = onReset;  }, [onReset]);
  useEffect(() => {    if (resetAfterMs <= 0 || (status !== "success" && status !== "error")) return;    const t = window.setTimeout(() => onResetRef.current?.(), resetAfterMs);    return () => window.clearTimeout(t);  }, [status, resetAfterMs]);
  // The button's width follows its content, so it morphs between labels  // instead of snapping.  useLayoutEffect(() => {    const el = contentRef.current;    if (!el) return;    const measure = () => setWidth(el.offsetWidth + PAD_X * 2);    measure();    const ro = new ResizeObserver(measure);    ro.observe(el);    return () => ro.disconnect();  }, []);
  // Outcome feedback: a ring for success, a head-shake for errors.  useEffect(() => {    if (prevStatus.current === status) return;    prevStatus.current = status;    if (reduce) return;    if (status === "success") setBurst((n) => n + 1);    if (status === "error" && scope.current) {      animate(scope.current, { x: [0, -7, 6, -4, 3, 0] }, { duration: 0.42, ease: "easeOut" });    }  }, [status, reduce, animate, scope]);
  const label = { idle: idleLabel, loading: loadingLabel, success: successLabel, error: errorLabel }[status];  const icon =    status === "loading" ? (      <Spinner />    ) : status === "success" ? (      <DrawnGlyph kind="check" reduce={reduce} />    ) : status === "error" ? (      <DrawnGlyph kind="cross" reduce={reduce} />    ) : (      idleIcon ?? null    );  const isBusy = status === "loading";
  const swap = {    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 10, filter: "blur(4px)" },    animate: { opacity: 1, y: 0, filter: "blur(0px)" },    exit: reduce ? { opacity: 0 } : { opacity: 0, y: -10, filter: "blur(4px)" },    transition: { duration: 0.28, ease: EASE_OUT },  };
  return (    <span className="relative inline-grid">      <AnimatePresence>        {burst > 0 && status === "success" ? (          <motion.span            key={burst}            aria-hidden            initial={{ opacity: 0.55, scale: 1 }}            animate={{ opacity: 0, scale: 1.25 }}            exit={{ opacity: 0 }}            transition={{ duration: 0.7, ease: EASE_OUT }}            className="pointer-events-none absolute inset-0 rounded-full bg-emerald-500/40"          />        ) : null}      </AnimatePresence>      <motion.button        ref={scope}        type={type}        disabled={disabled || isBusy}        onClick={onClick}        aria-busy={isBusy}        initial={false}        animate={width ? { width } : undefined}        transition={reduce ? { duration: 0 } : WIDTH_SPRING}        whileTap={reduce || isBusy ? undefined : { scale: 0.97 }}        className={cn(          "relative inline-flex h-10 items-center justify-center overflow-hidden rounded-full",          "text-[14px] font-medium whitespace-nowrap outline-none select-none",          "shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_1px_2px_rgba(0,0,0,0.12),0_6px_16px_-6px_rgba(0,0,0,0.3)]",          "transition-[background-color,color,opacity] duration-300 ease-out",          "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",          "disabled:cursor-default",          disabled && "opacity-50",          (status === "idle" || status === "loading") && "bg-(--color-ink) text-(--color-paper)",          status === "success" && "bg-emerald-600 text-white",          status === "error" && "bg-red-600 text-white",          className,        )}        style={{ paddingInline: PAD_X }}      >        <span ref={contentRef} className="relative inline-flex w-max items-center gap-2">          <AnimatePresence initial={false} mode="popLayout">            {icon ? (              <motion.span                key={status}                aria-hidden                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, filter: "blur(3px)" }}                animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, filter: "blur(3px)" }}                transition={{ type: "spring", bounce: 0.3, duration: 0.4 }}                className="flex size-4 shrink-0 items-center justify-center [&_svg]:size-full"              >                {icon}              </motion.span>            ) : null}          </AnimatePresence>          <AnimatePresence initial={false} mode="popLayout">            <motion.span key={label} {...swap} className="inline-block">              {label}            </motion.span>          </AnimatePresence>        </span>      </motion.button>      {/* Announce status changes without making the button's name noisy. */}      <span role="status" aria-live="polite" className="sr-only">        {status === "idle" ? "" : label}      </span>    </span>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { MorphButton, type MorphButtonStatus } from "@/components/ui/morph-button";
const [status, setStatus] = useState<MorphButtonStatus>("idle");
<MorphButton  status={status}  onClick={async () => {    setStatus("loading");    try {      await sendInvite();      setStatus("success");    } catch {      setStatus("error");    }  }}  onReset={() => setStatus("idle")}  resetAfterMs={2000}  idleIcon={<Send />}  idleLabel="Send invite"  loadingLabel="Sending"  successLabel="Invite sent"  errorLabel="Couldn't send"/>

Props

PropTypeRequiredDefaultDescription
status"idle" | "loading" | "success" | "error"No"idle"Controlled state. The button is disabled while loading.
onClick() => voidNo—Click handler.
idleLabel / loadingLabel / successLabel / errorLabelstringNo"Send invite" / "Sending" / "Sent" / "Failed"Label for each state. The width follows whichever is showing.
idleIconReactNodeNo—Optional icon before the idle label.
resetAfterMsnumberNo0Calls onReset this long after success or error. 0 never resets.
onReset() => voidNo—Return the status to idle here.
disabled / type / classNameboolean / string / stringNo—Passed to the button.

Best Practices

  1. Keep labels short and similar in length, so the width change reads as a morph rather than a jump.
  2. Always handle the error state; a button stuck on a spinner is worse than a plain one.
  3. Use resetAfterMs for repeatable actions, and leave it at 0 for one-off actions like "Published".
  4. Reduced motion keeps the colour change and swaps the slides, ring and shake for fades.