Feedback

Upload Progress

File upload row with pause/resume icon swap, a bar that morphs into a drawn success check on completion, and a one-shot error shake with retry.

Install

npx shadcn@latest add @paragon/upload-progress

Also installs: success-check

upload-progress.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { FileText, Pause, Play, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import { SuccessCheck } from "@/registry/paragon/ui/success-check";

export type UploadStatus = "uploading" | "paused" | "complete" | "error";

export interface UploadProgressProps extends React.ComponentProps<"div"> {
  fileName: string;
  /** Formatted size string, e.g. "4.2 MB". */
  fileSize?: string;
  /** 0–100. */
  progress: number;
  status?: UploadStatus;
  onPause?: () => void;
  onResume?: () => void;
  onRetry?: () => void;
  /** Icon shown beside the file name. */
  icon?: React.ReactNode;
  errorMessage?: string;
}

const iconSwap = {
  initial: { opacity: 0, scale: 0.25, filter: "blur(4px)" },
  animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
  exit: { opacity: 0, scale: 0.25, filter: "blur(4px)" },
  transition: { type: "spring", duration: 0.3, bounce: 0 },
} as const;

/**
 * File upload row. Progress retargets interruptibly via transform; the
 * pause/resume control swaps icons with the house recipe; completion
 * collapses the bar and morphs the control into a drawn success check;
 * errors shake the row once (reduced motion: color only) and offer retry.
 */
export function UploadProgress({
  fileName,
  fileSize,
  progress,
  status = "uploading",
  onPause,
  onResume,
  onRetry,
  icon,
  errorMessage = "Upload failed",
  className,
  ...props
}: UploadProgressProps) {
  const pct = Math.min(100, Math.max(0, progress));
  const failed = status === "error";
  const complete = status === "complete";

  return (
    <div
      data-upload-row={failed ? "" : undefined}
      className={cn(
        "flex w-full items-center gap-3 rounded-xl bg-card p-3.5 shadow-border",
        className,
      )}
      {...props}
    >
      <style href="paragon-upload-progress" precedence="paragon">{`
        @keyframes pg-upload-shake {
          0%, 100% { transform: translateX(0); }
          25% { transform: translateX(-4px); }
          50% { transform: translateX(4px); }
          75% { transform: translateX(-2px); }
        }
        [data-upload-row] {
          animation: pg-upload-shake 300ms var(--ease-in-out) 1;
        }
        @media (prefers-reduced-motion: reduce) {
          [data-upload-row] { animation: none; }
        }
      `}</style>
      <span
        aria-hidden
        className={cn(
          "flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground [&_svg]:size-4",
          failed && "text-destructive",
        )}
      >
        {icon ?? <FileText />}
      </span>

      <div className="min-w-0 flex-1">
        <div className="flex items-baseline justify-between gap-3">
          <p className="truncate text-[13px] font-medium leading-5">
            {fileName}
          </p>
          <p
            role="status"
            className={cn(
              "shrink-0 text-xs text-muted-foreground tabular-nums",
              failed && "text-destructive",
            )}
          >
            {failed
              ? errorMessage
              : complete
                ? "Complete"
                : status === "paused"
                  ? `Paused · ${Math.round(pct)}%`
                  : `${fileSize ? `${fileSize} · ` : ""}${Math.round(pct)}%`}
          </p>
        </div>

        {/* Bar collapses away on completion; the check takes over. */}
        <div
          className={cn(
            "grid transition-[grid-template-rows,opacity] duration-250 ease-[var(--ease-out)] motion-reduce:transition-[opacity]",
            complete
              ? "grid-rows-[0fr] opacity-0 motion-reduce:grid-rows-[1fr]"
              : "grid-rows-[1fr] opacity-100",
          )}
        >
          <div className="overflow-hidden">
            <div
              role="progressbar"
              aria-label={`Uploading ${fileName}`}
              aria-valuemin={0}
              aria-valuemax={100}
              aria-valuenow={Math.round(pct)}
              className="mt-2 mb-0.5 h-1 overflow-hidden rounded-full bg-secondary"
            >
              <div
                className={cn(
                  "h-full w-full rounded-full transition-[transform,background-color] duration-250 ease-[var(--ease-out)]",
                  failed ? "bg-destructive" : "bg-primary",
                  status === "paused" && "bg-muted-foreground/50",
                )}
                style={{ transform: `translateX(-${100 - pct}%)` }}
              />
            </div>
          </div>
        </div>
      </div>

      <span className="relative flex size-7 shrink-0 items-center justify-center">
        <AnimatePresence mode="popLayout" initial={false}>
          {complete ? (
            <motion.span key="check" {...iconSwap}>
              <SuccessCheck size={22} strokeWidth={4} label="Upload complete" />
            </motion.span>
          ) : (
            <motion.span key={failed ? "retry" : status} {...iconSwap}>
              <button
                type="button"
                aria-label={
                  failed
                    ? `Retry uploading ${fileName}`
                    : status === "paused"
                      ? `Resume uploading ${fileName}`
                      : `Pause uploading ${fileName}`
                }
                onClick={
                  failed
                    ? onRetry
                    : status === "paused"
                      ? onResume
                      : onPause
                }
                className="pressable relative flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 hover:text-foreground after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2"
              >
                {failed ? (
                  <RotateCcw className="size-3.5" />
                ) : status === "paused" ? (
                  <Play className="size-3.5" />
                ) : (
                  <Pause className="size-3.5" />
                )}
              </button>
            </motion.span>
          )}
        </AnimatePresence>
      </span>
    </div>
  );
}