Upload Progress
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/retry control is one persistent button (focus survives state
 * changes) whose glyph swaps with the house icon recipe; completion collapses
 * the bar and morphs the control into a drawn success check; errors shake the
 * row once — a 4-leg keyframe with per-segment easing, orthogonal to the
 * error tint so nothing flickers (reduced motion: color only) — and offer
 * retry. Only terminal states are announced, never percent ticks.
 */
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% { transform: translateX(0); animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97); }
          25% { transform: translateX(-5px); animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97); }
          50% { transform: translateX(4px); animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97); }
          75% { transform: translateX(-2px); animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97); }
          100% { transform: translateX(0); }
        }
        [data-upload-row] {
          animation: pg-upload-shake 350ms 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 transition-[background-color,color] duration-(--duration-base) [&_svg]:size-4",
          failed && "bg-destructive/10 text-destructive",
          complete && "bg-success/10 text-success",
        )}
      >
        {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
            className={cn(
              "shrink-0 text-xs text-muted-foreground tabular-nums",
              failed && "text-destructive",
              complete && "text-success",
            )}
          >
            {failed
              ? errorMessage
              : complete
                ? "Complete"
                : status === "paused"
                  ? `Paused · ${Math.round(pct)}%`
                  : `${fileSize ? `${fileSize} · ` : ""}${Math.round(pct)}%`}
          </p>
        </div>
        {/* Announce terminal states only — percent ticks would be noise. */}
        <span role="status" className="sr-only">
          {failed
            ? `${fileName}: ${errorMessage}`
            : complete
              ? `${fileName}: upload complete`
              : status === "paused"
                ? `${fileName}: upload paused`
                : ""}
        </span>

        {/* 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="control" {...iconSwap}>
              {/* One persistent button across pause/resume/retry, so focus
                  never drops mid-interaction; only the glyph swaps. */}
              <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"
              >
                <AnimatePresence mode="popLayout" initial={false}>
                  <motion.span
                    key={failed ? "retry" : status === "paused" ? "play" : "pause"}
                    className="flex"
                    {...iconSwap}
                  >
                    {failed ? (
                      <RotateCcw className="size-3.5" />
                    ) : status === "paused" ? (
                      <Play className="size-3.5" />
                    ) : (
                      <Pause className="size-3.5" />
                    )}
                  </motion.span>
                </AnimatePresence>
              </button>
            </motion.span>
          )}
        </AnimatePresence>
      </span>
    </div>
  );
}