Action Button

Craft

Button 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-button

Source

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 }