Morph Label
.mdA 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
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
| Prop | Type | Default | Description |
|---|---|---|---|
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 label | State 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). |
resetAfter | number | 1800 | ms before returning to idle; 0 keeps the done state until clicked again. |
variant | ButtonVariant | "primary" | Button variant at rest. |
doneVariant | ButtonVariant | "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 animateWidth | boolean | true | SwapSlot: 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.