# Morphing Upload Button

> A button that morphs into its own progress bar, then a check mark.

A shadcn/ui block from the [@uploadcn](https://uploadcn.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uploadcn/upload-morph-button).

- Page, with a live preview: https://shadcn.exchange/r/uploadcn/upload-morph-button
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uploadcn/upload-morph-button-light-d77f5cb9e86cc5ed.png
- Registry item: https://uploadcn.dev/r/new-york-v4/upload-morph-button.json
- Categories: upload, animated
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://uploadcn.dev/r/new-york-v4/upload-morph-button.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@uploadcn/core`, `@uploadcn/react`, `cn`, `motion`
- Registry items: `@uploadcn/upload`, `button`

## Files

### `registry/default/blocks/upload-morph-button.tsx`

```tsx
"use client"

import * as React from "react"
import {
  type UploadAdapter,
  type Uploader,
  type UploadItem as UploadItemData,
  type ValidationOptions,
} from "@uploadcn/core"
import {
  Upload as UploadPrimitive,
  useUploadContext,
  useUploadProgress,
  type UploadRootProps,
} from "@uploadcn/react"
import { cn } from "cn"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

import { IconPlaceholder } from "@/components/icon-placeholder"
import { buttonVariants } from "@/components/ui/button"
import { Upload } from "@/registry/default/ui/upload"

interface UploadMorphButtonProps<TResult> extends Pick<
  ValidationOptions,
  "accept" | "maxSize" | "maxFiles"
> {
  adapter?: UploadAdapter<TResult>
  /** Use an uploader you created with `useUploader` instead of options. */
  uploader?: Uploader<TResult>
  children?: React.ReactNode
  multiple?: boolean
  onSuccess?: (item: UploadItemData<TResult>) => void
  onComplete?: (items: UploadItemData<TResult>[]) => void
  className?: string
}

/**
 * A button that morphs into its own progress bar while uploading, then
 * into a check mark, no extra UI needed.
 */
function UploadMorphButton<TResult = unknown>({
  adapter,
  uploader,
  children = "Upload",
  className,
  ...options
}: UploadMorphButtonProps<TResult>) {
  return (
    <Upload
      {...((uploader
        ? { uploader }
        : {
            adapter,
            ...options,
          }) as UploadRootProps<TResult>)}
      className={cn("w-auto items-start", className)}
    >
      <MorphTrigger label={children} />
    </Upload>
  )
}

type Phase = "idle" | "uploading" | "done" | "error"

function MorphTrigger({ label }: { label: React.ReactNode }) {
  const { uploader } = useUploadContext()
  const summary = useUploadProgress(uploader)
  const reduceMotion = useReducedMotion()
  const [showDone, setShowDone] = React.useState(false)

  React.useEffect(() => {
    let timer: ReturnType<typeof setTimeout> | undefined
    const unsubscribe = uploader.on("complete", ({ items }) => {
      if (!items.some((item) => item.status === "success")) return
      setShowDone(true)
      clearTimeout(timer)
      timer = setTimeout(() => setShowDone(false), 2200)
    })
    return () => {
      unsubscribe()
      clearTimeout(timer)
    }
  }, [uploader])

  const phase: Phase = summary.isUploading
    ? "uploading"
    : showDone
      ? "done"
      : summary.hasErrors
        ? "error"
        : "idle"

  const spring = reduceMotion
    ? { duration: 0 }
    : { type: "spring" as const, bounce: 0.25, duration: 0.5 }

  return (
    <UploadPrimitive.Trigger
      data-slot="upload-morph-button"
      data-phase={phase}
      disabled={phase === "uploading"}
      aria-live="polite"
      render={
        <motion.button
          layout
          transition={spring}
          style={{ borderRadius: 999 }}
          className={cn(
            buttonVariants({ size: "lg" }),
            "relative h-10 overflow-hidden px-5 disabled:opacity-100",
            phase === "done" && "w-10 px-0",
            phase === "error" &&
              "bg-destructive text-white hover:bg-destructive/90"
          )}
        />
      }
    >
      {phase === "uploading" ? (
        <motion.span
          aria-hidden
          className="absolute inset-y-0 left-0 bg-primary-foreground/15"
          initial={{ width: 0 }}
          animate={{ width: `${summary.percent}%` }}
          transition={{ ease: "easeOut", duration: 0.3 }}
        />
      ) : null}
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={phase}
          className="relative flex items-center gap-2 whitespace-nowrap"
          initial={
            reduceMotion ? false : { opacity: 0, y: 8, filter: "blur(4px)" }
          }
          animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
          exit={
            reduceMotion
              ? undefined
              : { opacity: 0, y: -8, filter: "blur(4px)" }
          }
          transition={{ duration: 0.2 }}
        >
          {phase === "idle" ? (
            <>
              <IconPlaceholder
                lucide="UploadIcon"
                tabler="IconUpload"
                hugeicons="Upload01Icon"
                phosphor="UploadSimpleIcon"
                remixicon="RiUploadLine"
                data-icon="inline-start"
              />
              {label}
            </>
          ) : phase === "uploading" ? (
            <span className="tabular-nums">
              Uploading {Math.round(summary.percent)}%
            </span>
          ) : phase === "done" ? (
            <DrawnCheck label="Uploaded" />
          ) : (
            <>
              <IconPlaceholder
                lucide="CircleAlertIcon"
                tabler="IconExclamationCircle"
                hugeicons="AlertCircleIcon"
                phosphor="WarningCircleIcon"
                remixicon="RiErrorWarningLine"
                data-icon="inline-start"
              />
              Failed, try again
            </>
          )}
        </motion.span>
      </AnimatePresence>
    </UploadPrimitive.Trigger>
  )
}

function DrawnCheck({ label }: { label: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      className="size-5"
      fill="none"
      stroke="currentColor"
      strokeWidth={2.5}
      strokeLinecap="round"
      strokeLinejoin="round"
      role="img"
      aria-label={label}
    >
      <motion.path
        d="M5 12.5l4.5 4.5L19 7.5"
        initial={{ pathLength: 0 }}
        animate={{ pathLength: 1 }}
        transition={{ duration: 0.35, delay: 0.1, ease: "easeOut" }}
      />
    </svg>
  )
}

export { UploadMorphButton, type UploadMorphButtonProps }
```
