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/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>
);
}