# Button Save

> Save button that shows a spinner or filling progress bar, turns green with a check, then resets.

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

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/button-save.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/button-save-demo.tsx"
import { SaveButton } from "@/components/button-save"

export default function ButtonSaveDemo() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-4 p-8">
      <SaveButton />
      <SaveButton withProgress duration={2400} saveLabel="Save changes" />
    </div>
  )
}
```

## Source

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

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

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

const css = `@keyframes save-progress{from{transform:scaleX(0)}to{transform:scaleX(1)}}@media (prefers-reduced-motion:reduce){.save-progress-bar{animation-duration:1ms!important}}`

type Phase = "idle" | "saving" | "saved"

export interface SaveButtonProps extends Omit<React.ComponentProps<"button">, "children"> {
  /** Show a filling progress bar instead of a spinner while saving */
  withProgress?: boolean
  saveLabel?: string
  savedLabel?: string
  /** Simulated save duration in milliseconds */
  duration?: number
  onSave?: () => void
}

export function SaveButton({
  withProgress = false,
  saveLabel = "Save",
  savedLabel = "Saved",
  duration = 1600,
  onSave,
  className,
  onClick,
  ...props
}: SaveButtonProps) {
  const [phase, setPhase] = React.useState<Phase>("idle")
  const timers = React.useRef<Array<ReturnType<typeof setTimeout>>>([])

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

  function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
    onClick?.(event)
    if (phase !== "idle") return
    setPhase("saving")
    onSave?.()
    timers.current.push(
      setTimeout(() => {
        setPhase("saved")
        timers.current.push(setTimeout(() => setPhase("idle"), 1800))
      }, duration)
    )
  }

  return (
    <>
      <style href="button-save" precedence="default">
        {css}
      </style>
      <button
        type="button"
        aria-busy={phase === "saving"}
        className={cn(
          "relative inline-flex h-10 min-w-32 items-center justify-center gap-2 overflow-hidden rounded-lg px-5 text-sm font-medium shadow-xs transition-colors duration-300",
          phase === "saved" ? "bg-emerald-600 text-white" : "bg-primary text-primary-foreground hover:bg-primary/90",
          "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
          "disabled:pointer-events-none disabled:opacity-50",
          phase === "saving" && "cursor-progress",
          className
        )}
        onClick={handleClick}
        {...props}
      >
        {withProgress && phase === "saving" && (
          <span
            aria-hidden
            className="save-progress-bar pointer-events-none absolute inset-0 origin-left bg-white/25"
            style={{ animation: `save-progress ${duration}ms ease-in-out forwards` }}
          />
        )}
        <span className="relative inline-flex items-center gap-2">
          {phase === "idle" && <Save className="size-4" />}
          {phase === "saving" &&
            (withProgress ? <span className="size-4" aria-hidden /> : <Loader2 className="size-4 animate-spin" />)}
          {phase === "saved" && <Check className="size-4" />}
          {phase === "idle" ? saveLabel : phase === "saving" ? "Saving…" : savedLabel}
        </span>
      </button>
    </>
  )
}
```
