Action Button
CraftButton that runs an async action and morphs through loading → success / error, then resets. For save, copy, send.
Motion: Width springs to each label (morph spring), icons pop in; error shakes once. Success / error haptics.
Installation
npx shadcn@latest add @craft/action-buttonSource
components/ui/action-button.tsx
"use client"
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { AnimatePresence, motion, useAnimate, type HTMLMotionProps } from "motion/react"
import { CheckCircleIcon, DangerCircleIcon } from "@solar-icons/react/linear"
import { cn } from "cn"
import { buttonVariants } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { haptic, useHaptics } from "@/lib/haptics"
import { iconSwap, spring } from "@/lib/motion"
type ActionState = "idle" | "loading" | "success" | "error"
type ActionButtonProps = Omit<HTMLMotionProps<"button">, "onClick" | "children" | "layout"> &
VariantProps<typeof buttonVariants> & {
/** Runs on click. Return a promise to show loading → success, or throw/reject to show the error state. */
onAction: () => unknown | Promise<unknown>
children: string
/** Label while the action runs (default: the idle label) */
loadingLabel?: string
successLabel?: string
errorLabel?: string
/** ms before returning to idle after success/error */
resetAfter?: number
}
/**
* A button that morphs through its action: label → spinner + loadingLabel →
* ✓ successLabel (or ⚠ errorLabel with a shake) → back to idle. Width
* springs to fit each label. Fires
* success / error haptics on phones and announces the result to screen readers.
* Use for actions that finish in place (save, copy, send) — not for navigation.
*/
function ActionButton({
onAction,
children,
loadingLabel,
successLabel = "Done",
errorLabel = "Try again",
resetAfter = 1800,
variant,
size,
className,
disabled,
...props
}: ActionButtonProps) {
const [state, setState] = React.useState<ActionState>("idle")
const [scope, animate] = useAnimate<HTMLButtonElement>()
const { trigger } = useHaptics()
const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
React.useEffect(() => () => clearTimeout(timer.current), [])
const run = async () => {
if (state === "loading") return
clearTimeout(timer.current)
trigger(haptic.press)
setState("loading")
try {
await onAction()
setState("success")
trigger(haptic.success)
} catch {
setState("error")
trigger(haptic.error)
void animate(scope.current, { x: [0, -5, 5, -3, 3, 0] }, { duration: 0.32 })
}
timer.current = setTimeout(() => setState("idle"), resetAfter)
}
const label = { idle: children, loading: loadingLabel ?? children, success: successLabel, error: errorLabel }[state]
const icon =
state === "loading" ? <Spinner /> : state === "success" ? <CheckCircleIcon /> : state === "error" ? <DangerCircleIcon /> : null
return (
<motion.button
ref={scope}
type="button"
layout
transition={spring.morph}
style={{ borderRadius: 9999 }}
data-slot="action-button"
data-state={state}
aria-busy={state === "loading" || undefined}
disabled={disabled}
onClick={run}
className={cn(
buttonVariants({ variant, size }),
"overflow-hidden",
state === "success" && variant !== "destructive" && "bg-success text-white hover:not-disabled:bg-success dark:text-background",
state === "error" && "bg-destructive/10 text-destructive hover:not-disabled:bg-destructive/15",
className
)}
{...props}
>
<motion.span layout="position" className="inline-flex items-center gap-1.5">
<AnimatePresence mode="popLayout" initial={false}>
{icon && (
<motion.span key={state} variants={iconSwap} initial="hidden" animate="visible" exit="exit" className="inline-flex">
{icon}
</motion.span>
)}
</AnimatePresence>
<span>{label}</span>
</motion.span>
{/* Result for screen readers — a label change alone isn't announced */}
<span className="sr-only" aria-live="polite">
{state === "success" ? successLabel : state === "error" ? errorLabel : ""}
</span>
</motion.button>
)
}
export { ActionButton }