# Loading State Button

> Stateful button cycling idle → loading → success → idle, with async onAction support.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/loading-state-button
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/loading-state-button.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/loading-state-button-demo.tsx"
import { LoadingStateButton } from "@/components/loading-state-button"

export default function LoadingStateButtonDemo() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-4 rounded-2xl bg-neutral-100 p-12 dark:bg-neutral-900">
      <LoadingStateButton />
      <LoadingStateButton label="Send message" loadingLabel="Sending" successLabel="Sent" duration={2200} />
    </div>
  )
}
```

## Source

```tsx title="components/loading-state-button.tsx"
"use client"

import * as React from "react"
import { Check, Loader2 } from "lucide-react"

import { cn } from "@/lib/utils"

type Phase = "idle" | "loading" | "success"

export interface LoadingStateButtonProps extends Omit<React.ComponentProps<"button">, "children"> {
  label?: string
  loadingLabel?: string
  successLabel?: string
  /** Fake request time in ms. Ignored when onAction returns a promise. */
  duration?: number
  onAction?: () => void | Promise<unknown>
}

export function LoadingStateButton({
  label = "Save changes",
  loadingLabel = "Saving",
  successLabel = "Saved",
  duration = 1500,
  onAction,
  onClick,
  className,
  ...props
}: LoadingStateButtonProps) {
  const [phase, setPhase] = React.useState<Phase>("idle")
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])

  React.useEffect(() => {
    const pending = timers.current
    return () => pending.forEach(clearTimeout)
  }, [])

  const later = (fn: () => void, ms: number) => {
    timers.current.push(setTimeout(fn, ms))
  }

  const finish = () => {
    setPhase("success")
    later(() => setPhase("idle"), 1600)
  }

  const text = phase === "loading" ? loadingLabel : phase === "success" ? successLabel : label

  return (
    <button
      type="button"
      aria-busy={phase === "loading"}
      onClick={(event) => {
        onClick?.(event)
        if (phase !== "idle") return
        setPhase("loading")
        const result = onAction?.()
        if (result && typeof (result as Promise<unknown>).then === "function") {
          void (result as Promise<unknown>).then(finish, () => setPhase("idle"))
        } else {
          later(finish, duration)
        }
      }}
      className={cn(
        "relative inline-flex h-11 min-w-40 items-center justify-center gap-2 overflow-hidden rounded-lg px-6 text-sm font-medium text-white select-none",
        "transition-[background-color,transform] duration-300 active:scale-[0.97]",
        phase === "success" ? "bg-emerald-600" : "bg-blue-600 hover:bg-blue-500",
        phase === "loading" && "cursor-progress",
        "focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-2 focus-visible:outline-none",
        "disabled:pointer-events-none disabled:opacity-50",
        className
      )}
      {...props}
    >
      {phase === "loading" && <Loader2 className="size-4 animate-spin" />}
      {phase === "success" && <Check className="size-4 animate-in zoom-in-50 duration-300" strokeWidth={3} />}
      <span aria-live="polite">{text}</span>
    </button>
  )
}
```
