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, useRef } from "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";};
function readTextSwapDur() {  const v = parseFloat(    getComputedStyle(document.documentElement).getPropertyValue(      "--text-swap-dur",    ),  );  return Number.isFinite(v) ? v : 150;}
export function MorphButton({  status = "idle",  onClick,  idleLabel = "Send invite",  loadingLabel = "Sending",  successLabel = "Sent",  errorLabel = "Failed",  resetAfterMs = 0,  onReset,  disabled,  className,  layoutId: _layoutId,  type = "button",}: MorphButtonProps) {  void _layoutId;  const textRef = useRef<HTMLSpanElement>(null);  const prevStatus = useRef(status);  const swapTimer = useRef<number | null>(null);
  const textMap = {    idle: idleLabel,    loading: loadingLabel,    success: successLabel,    error: errorLabel,  };
  // a = loading spinner, b = success/error glyph  const iconState =    status === "success" || status === "error"      ? "b"      : status === "loading"        ? "a"        : "a";  const showIcon = status !== "idle";
  useEffect(() => {    if (      resetAfterMs <= 0 ||      (status !== "success" && status !== "error")    ) {      return;    }    const t = window.setTimeout(() => onReset?.(), resetAfterMs);    return () => window.clearTimeout(t);  }, [status, resetAfterMs, onReset]);
  // Text states swap orchestration when the label changes with status  useEffect(() => {    const el = textRef.current;    if (!el) return;    const next = textMap[status];    if (prevStatus.current === status) {      el.textContent = next;      return;    }    prevStatus.current = status;
    if (swapTimer.current) window.clearTimeout(swapTimer.current);    const dur = readTextSwapDur();    el.classList.add("is-exit");    swapTimer.current = window.setTimeout(() => {      el.textContent = next;      el.classList.remove("is-exit");      el.classList.add("is-enter-start");      void el.offsetHeight;      el.classList.remove("is-enter-start");      swapTimer.current = null;    }, dur);
    return () => {      if (swapTimer.current) window.clearTimeout(swapTimer.current);    };    // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional label map from status  }, [status, idleLabel, loadingLabel, successLabel, errorLabel]);
  const isBusy = status === "loading";
  return (    <button      type={type}      disabled={disabled || isBusy}      onClick={onClick}      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,width] 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}    >      {showIcon ? (        <span          className="t-icon-swap inline-grid size-4 shrink-0 place-items-center"          data-state={iconState}          aria-hidden        >          <span className="t-icon" data-icon="a">            <Loader2 className="size-4 animate-spin stroke-[1.5]" />          </span>          <span className="t-icon" data-icon="b">            {status === "error" ? (              <X className="size-4 stroke-[1.5]" />            ) : (              <Check className="size-4 stroke-[1.5]" />            )}          </span>        </span>      ) : null}      <span ref={textRef} className="t-text-swap whitespace-nowrap">        {textMap[status]}      </span>    </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.