shadimage
corr
componentanimated-buttonssource: ok · hit

Animated Buttons

Animated action buttons that transition through hover, loading, and complete states.

npx shadcn add "https://ui.corr.sh/r/animated-buttons.json"
free

Where it goes

stats

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
iconslucide-react
animationmotion

Registry dependencies

button

Files1 · source cached 10/8/2026, 1:43:19 PM

registry/corr/components/animated-buttons.tsx
"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, X } from "lucide-react"

import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

type AnimatedButtonStatus = "idle" | "loading" | "success"

type AnimatedButtonProps = {
  label?: string
  loadingLabel?: string
  successLabel?: string
  icon?: React.ReactNode
  hoverIcon?: React.ReactNode
  iconOnlyOnHover?: boolean
  successIcon?: React.ReactNode
  tone?: "default" | "destructive"
  onAction?: () => void | Promise<void>
  disabled?: boolean
  resetDelay?: number
  className?: string
  variant?: "default" | "outline"
  minWidth?: string
}

export function AnimatedButton({
  label = "Confirm",
  loadingLabel = "Working",
  successLabel = "Done",
  icon,
  hoverIcon,
  iconOnlyOnHover = false,
  successIcon,
  tone = "default",
  onAction,
  disabled = false,
  resetDelay = 1100,
  className,
  variant,
  minWidth = "min-w-28",
}: AnimatedButtonProps) {
  const reduceMotion = useReducedMotion()
  const resetTimerRef = React.useRef<number | null>(null)
  const [status, setStatus] = React.useState<AnimatedButtonStatus>("idle")

  React.useEffect(() => {
    return () => {
      if (resetTimerRef.current) {
        window.clearTimeout(resetTimerRef.current)
      }
    }
  }, [])

  async function handleClick() {
    if (disabled || status !== "idle") return

    setStatus("loading")

    if (onAction) {
      await onAction()
    } else {
      await new Promise((resolve) => window.setTimeout(resolve, 850))
    }

    setStatus("success")
    resetTimerRef.current = window.setTimeout(
      () => {
        setStatus("idle")
      },
      reduceMotion ? 0 : resetDelay
    )
  }

  const contentTransition = reduceMotion
    ? { duration: 0 }
    : { duration: 0.16, ease: "easeOut" as const }
  const currentIcon =
    status === "idle"
      ? icon
      : status === "loading"
        ? "loading"
        : (successIcon ?? <Check className="size-3.5" />)
  const showIconSlot = Boolean(currentIcon)
  const effectiveVariant =
    variant ?? (tone === "destructive" ? "destructive" : "default")

  return (
    <Button
      type="button"
      variant={effectiveVariant}
      disabled={disabled || status !== "idle"}
      onClick={handleClick}
      className={cn(
        "group/animated-button relative overflow-hidden transition-colors",
        minWidth,
        status === "success" &&
          tone !== "destructive" &&
          "bg-emerald-600 text-white hover:bg-emerald-600",
        className
      )}
    >
      {status === "idle" && hoverIcon && !icon ? (
        <>
          <span className="transition-all duration-150 group-hover/animated-button:translate-y-1 group-hover/animated-button:opacity-0 group-hover/animated-button:blur-xs">
            {label}
          </span>
          <span className="absolute inline-flex size-3.5 translate-y-2 items-center justify-center opacity-0 transition-all duration-150 group-hover/animated-button:translate-y-0 group-hover/animated-button:opacity-100">
            {hoverIcon}
          </span>
        </>
      ) : status === "idle" && icon && iconOnlyOnHover ? (
        <>
          <span className="inline-flex items-center gap-1.5 transition-all duration-150 group-hover/animated-button:translate-y-1 group-hover/animated-button:opacity-0 group-hover/animated-button:blur-xs">
            <span className="inline-flex size-3.5 shrink-0 items-center justify-center">
              {icon}
            </span>
            {label}
          </span>
          <span className="absolute inline-flex size-3.5 translate-y-2 items-center justify-center opacity-0 transition-all duration-150 group-hover/animated-button:translate-y-0 group-hover/animated-button:opacity-100">
            {icon}
          </span>
        </>
      ) : (
        <AnimatePresence mode="wait" initial={false}>
          <motion.span
            key={status}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={contentTransition}
            className={cn(
              "inline-flex items-center",
              showIconSlot && "gap-1.5"
            )}
          >
            {showIconSlot ? (
              <span className="inline-flex size-3.5 shrink-0 items-center justify-center">
                {currentIcon === "loading" ? (
                  <span
                    className={cn(
                      "inline-flex size-3.5 items-center justify-center rounded-full border-2 border-current border-r-transparent",
                      !reduceMotion && "animate-spin"
                    )}
                    aria-hidden="true"
                  />
                ) : (
                  currentIcon
                )}
              </span>
            ) : null}
            {status === "idle"
              ? label
              : status === "loading"
                ? loadingLabel
                : successLabel}
          </motion.span>
        </AnimatePresence>
      )}
    </Button>
  )
}

export function AnimatedButtons({
  confirmLabel = "Confirm",
  cancelLabel = "Cancel",
  loadingLabel = "Working",
  successLabel = "Done",
  confirmIcon,
  successIcon,
  tone = "default",
  onAction,
  onCancel,
  disabled = false,
  resetDelay = 1100,
  className,
}: {
  confirmLabel?: string
  cancelLabel?: string
  loadingLabel?: string
  successLabel?: string
  confirmIcon?: React.ReactNode
  successIcon?: React.ReactNode
  tone?: "default" | "destructive"
  onAction?: () => void | Promise<void>
  onCancel?: () => void
  disabled?: boolean
  resetDelay?: number
  className?: string
}) {
  return (
    <div
      className={cn(
        "inline-flex items-center gap-2 rounded-md border bg-card p-1.5 shadow-sm",
        className
      )}
    >
      <Button
        type="button"
        variant="outline"
        disabled={disabled}
        onClick={onCancel}
        className="group/cancel relative min-w-24 overflow-hidden"
      >
        <span className="transition-all duration-150 group-hover/cancel:translate-y-1 group-hover/cancel:opacity-0 group-hover/cancel:blur-xs">
          {cancelLabel}
        </span>
        <X className="absolute size-3.5 translate-y-2 opacity-0 transition-all duration-150 group-hover/cancel:translate-y-0 group-hover/cancel:opacity-100" />
      </Button>

      <AnimatedButton
        label={confirmLabel}
        loadingLabel={loadingLabel}
        successLabel={successLabel}
        icon={confirmIcon}
        hoverIcon={!confirmIcon ? <Check className="size-3.5" /> : undefined}
        successIcon={successIcon}
        tone={tone}
        onAction={onAction}
        disabled={disabled}
        resetDelay={resetDelay}
      />
    </div>
  )
}

More from corr

Browse all components in this library →