Skip to content
GetUrUI

Morph Label

.md

A button that confirms in place: its label and icon morph through pending, done or error (Add to cart → Added → back) while the width springs to fit, plus a SwapSlot for any value that changes.

buttonctastatefeedbackcrossfadeadd to carttoggletext swap

Installation

$npx shadcn@latest add @geturui/morph-label
Download source (.zip)

Also installs: Button

Usage

usage.tsx
1import { MorphLabel, SwapSlot } from "@/components/ui/morph-label";
2import { Check, ShoppingBag } from "lucide-react";
3
4export function AddToCart({ onAdd }: { onAdd: () => Promise<void> }) {
5  return (
6    <MorphLabel
7      idle={{ label: "Add to cart", icon: <ShoppingBag className="size-4" /> }}
8      done={{ label: "Added", icon: <Check className="size-4" /> }}
9      onAction={onAdd}
10    />
11  );
12}
13
14export function Price({ amount }: { amount: number }) {
15  return <SwapSlot value={amount}>${amount}/mo</SwapSlot>;
16}

Props

PropTypeDefaultDescription
idle*{ label: ReactNode; icon?: ReactNode }—Resting state.
done*{ label: ReactNode; icon?: ReactNode }—State shown after the action resolves.
pending{ label: ReactNode; icon?: ReactNode }spinner + idle labelState while the action's promise is pending.
error{ label: ReactNode; icon?: ReactNode }—Shown for 1.4s with a shake when the action throws.
onAction() => void | Promise<unknown>—Runs on click from idle. The pending state only shows when it returns a promise.
onUndo() => void | Promise<unknown>—Runs on click from done when resetAfter is 0 (toggle behaviour).
resetAfternumber1800ms before returning to idle; 0 keeps the done state until clicked again.
variantButtonVariant"primary"Button variant at rest.
doneVariantButtonVariant"secondary"Button variant in the done state.
size"sm" | "md" | "lg""md"Button size.
SwapSlot value*string | number | boolean—SwapSlot: when it changes, the old child blurs out and the new one slides in.
SwapSlot direction"up" | "down" | "left" | "right""up"SwapSlot: direction the new content travels from.
SwapSlot animateWidthbooleantrueSwapSlot: spring the slot's width to fit the new content.

Accessibility

  • A real button: aria-busy while pending, aria-pressed when used as a toggle (resetAfter={0}).
  • The done and error labels are announced through a polite live region.
  • Errors use the danger variant plus a label and icon, not colour alone.
  • Under reduced motion swaps become plain crossfades and the press scale and shake are skipped.

Related