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.
npx shadcn@latest add @paragon/upload-progressAlso installs: success-check
"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>
);
}