Overview

A submit-style control that morphs width and content across idle → loading → success | error. Icons cross-fade with scale + blur (not a hard toggle). First paint skips enter animation via initial={false}.


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, useId } from "react";import {  AnimatePresence,  motion,  useReducedMotion,} from "motion/react";import { Check, Loader2, X } from "lucide-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;  /** Auto-return to idle after success/error (ms). 0 = never. */  resetAfterMs?: number;  onReset?: () => void;  disabled?: boolean;  className?: string;  layoutId?: string;  type?: "button" | "submit" | "reset";};
const ICON_SPRING = { type: "spring" as const, bounce: 0, duration: 0.3 };const LAYOUT_SPRING = { type: "spring" as const, bounce: 0, duration: 0.35 };
export function MorphButton({  status = "idle",  onClick,  idleLabel = "Send invite",  loadingLabel = "Sending",  successLabel = "Sent",  errorLabel = "Failed",  resetAfterMs = 0,  onReset,  disabled,  className,  layoutId: layoutIdProp,  type = "button",}: MorphButtonProps) {  const reduce = useReducedMotion();  const reactId = useId();  const layoutId = layoutIdProp ?? `morph-btn-${reactId}`;
  const textMap = {    idle: idleLabel,    loading: loadingLabel,    success: successLabel,    error: errorLabel,  };
  useEffect(() => {    if (      resetAfterMs <= 0 ||      (status !== "success" && status !== "error")    ) {      return;    }    const t = window.setTimeout(() => onReset?.(), resetAfterMs);    return () => window.clearTimeout(t);  }, [status, resetAfterMs, onReset]);
  const isBusy = status === "loading";
  return (    <motion.button      type={type}      layout      layoutId={layoutId}      disabled={disabled || isBusy}      onClick={onClick}      transition={reduce ? { duration: 0.15 } : LAYOUT_SPRING}      whileTap={        disabled || isBusy || reduce ? undefined : { scale: 0.96 }      }      className={cn(        "relative inline-flex h-10 min-w-30 items-center justify-center gap-2 overflow-hidden",        "rounded-md px-4 text-sm font-medium",        "outline-none select-none",        "transition-[background-color,color,box-shadow] duration-(--dur-short) ease-out",        "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2",        "disabled:pointer-events-none disabled:opacity-60",        status === "idle" &&          "bg-(--color-ink) text-(--color-paper) shadow-[0_1px_2px_rgba(0,0,0,0.08)]",        status === "loading" &&          "bg-(--color-ink) text-(--color-paper)",        status === "success" &&          "bg-emerald-700 text-white dark:bg-emerald-600",        status === "error" && "bg-red-700 text-white dark:bg-red-600",        className,      )}      aria-live="polite"      aria-busy={isBusy}    >      <AnimatePresence mode="wait" initial={false}>        <motion.span          key={status}          className="inline-flex items-center gap-2"          initial={            reduce              ? { opacity: 0 }              : { opacity: 0, scale: 0.25, filter: "blur(4px)" }          }          animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}          exit={            reduce              ? { opacity: 0 }              : { opacity: 0, scale: 0.25, filter: "blur(4px)" }          }          transition={            reduce ? { duration: 0.12 } : ICON_SPRING          }        >          {status === "loading" && (            <Loader2              className="size-4 shrink-0 animate-spin stroke-[1.5]"              aria-hidden            />          )}          {status === "success" && (            <Check className="size-4 shrink-0 stroke-[1.5]" aria-hidden />          )}          {status === "error" && (            <X className="size-4 shrink-0 stroke-[1.5]" aria-hidden />          )}          <span className="whitespace-nowrap">{textMap[status]}</span>        </motion.span>      </AnimatePresence>    </motion.button>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { MorphButton } from "@/components/ui/morph-button";
<MorphButton  status={status}  onClick={send}  onReset={() => setStatus("idle")}  resetAfterMs={2000}  idleLabel="Send invite"  loadingLabel="Sending"  successLabel="Sent"/>

Props

PropTypeRequiredDefaultDescription
status"idle" | "loading" | "success" | "error"No"idle"Controlled visual state.
onClick() => voidNoClick handler (ignored while loading).
idleLabelstringNo"Send invite"Label for the idle state.
loadingLabelstringNo"Sending"Label while loading.
successLabelstringNo"Sent"Label on success.
errorLabelstringNo"Failed"Label on error.
resetAfterMsnumberNo0Auto-call onReset after success/error. 0 disables.
onReset() => voidNoCalled when resetAfterMs elapses.
layoutIdstringNoShared layout id for the shell morph.
classNamestringNoOptional class names on the button.

Best Practices

  1. Own status in the parent — this component is presentational about the state machine.
  2. Icon enter uses scale 0.25 → 1, opacity, and blur 4px → 0 with bounce: 0.
  3. Press scale is 0.96 and disabled during loading.
  4. Prefer resetAfterMs for success toast-like feedback; keep error sticky until the user retries.