Upload Progress
Original · freefile upload progress rows
byWeb InnoventixReact + Tailwind
proguploadprogress
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/prog-upload.jsonprog-upload.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useReducedMotion } from "motion/react";
type FileKind = "image" | "video" | "pdf" | "zip" | "doc";
type UploadStatus = "uploading" | "paused" | "done" | "error";
interface UploadFile {
id: string;
name: string;
kind: FileKind;
sizeBytes: number;
uploadedBytes: number;
bytesPerTick: number;
status: UploadStatus;
}
interface IconProps {
className?: string;
}
const KB = 1024;
const MB = 1024 * 1024;
const TICK_MS = 200;
const TICKS_PER_SEC = 1000 / TICK_MS;
function formatBytes(n: number): string {
if (n >= MB) return `${(n / MB).toFixed(1)} MB`;
if (n >= KB) return `${Math.round(n / KB)} KB`;
return `${Math.round(n)} B`;
}
function formatSpeed(bytesPerTick: number): string {
return `${formatBytes(bytesPerTick * TICKS_PER_SEC)}/s`;
}
function formatEta(remaining: number, bytesPerTick: number): string {
const perSec = bytesPerTick * TICKS_PER_SEC;
if (perSec <= 0) return "";
const secs = Math.max(1, Math.round(remaining / perSec));
if (secs < 60) return `${secs}s left`;
const m = Math.floor(secs / 60);
const s = secs % 60;
return s === 0 ? `${m}m left` : `${m}m ${s}s left`;
}
const KIND_META: Record<FileKind, { chipBg: string; chipFg: string; label: string }> = {
image: { chipBg: "bg-sky-100 dark:bg-sky-500/15", chipFg: "text-sky-600 dark:text-sky-400", label: "Image" },
video: { chipBg: "bg-violet-100 dark:bg-violet-500/15", chipFg: "text-violet-600 dark:text-violet-400", label: "Video" },
pdf: { chipBg: "bg-rose-100 dark:bg-rose-500/15", chipFg: "text-rose-600 dark:text-rose-400", label: "PDF" },
zip: { chipBg: "bg-amber-100 dark:bg-amber-500/15", chipFg: "text-amber-600 dark:text-amber-400", label: "Archive" },
doc: { chipBg: "bg-indigo-100 dark:bg-indigo-500/15", chipFg: "text-indigo-600 dark:text-indigo-400", label: "Document" },
};
const SEED_FILES: UploadFile[] = [
{ id: "seed-pdf", name: "Q3-financial-report.pdf", kind: "pdf", sizeBytes: 2.1 * MB, uploadedBytes: 2.1 * MB, bytesPerTick: 0.3 * MB, status: "done" },
{ id: "seed-img", name: "hero-banner-2400x1200.png", kind: "image", sizeBytes: 4.2 * MB, uploadedBytes: 4.2 * MB * 0.62, bytesPerTick: 0.085 * MB, status: "uploading" },
{ id: "seed-vid", name: "product-demo-final.mp4", kind: "video", sizeBytes: 128 * MB, uploadedBytes: 128 * MB * 0.24, bytesPerTick: 0.82 * MB, status: "uploading" },
{ id: "seed-zip", name: "brand-assets-bundle.zip", kind: "zip", sizeBytes: 56 * MB, uploadedBytes: 56 * MB * 0.41, bytesPerTick: 0.5 * MB, status: "error" },
{ id: "seed-doc", name: "onboarding-checklist.docx", kind: "doc", sizeBytes: 0.32 * MB, uploadedBytes: 0.32 * MB * 0.45, bytesPerTick: 0.02 * MB, status: "paused" },
];
const FILE_POOL: ReadonlyArray<{ name: string; kind: FileKind; sizeBytes: number; bytesPerTick: number }> = [
{ name: "customer-interview-notes.pdf", kind: "pdf", sizeBytes: 1.4 * MB, bytesPerTick: 0.24 * MB },
{ name: "landing-page-mockup.png", kind: "image", sizeBytes: 3.6 * MB, bytesPerTick: 0.11 * MB },
{ name: "release-walkthrough.mp4", kind: "video", sizeBytes: 96 * MB, bytesPerTick: 0.9 * MB },
{ name: "design-tokens-export.zip", kind: "zip", sizeBytes: 12 * MB, bytesPerTick: 0.42 * MB },
{ name: "press-kit-2026.docx", kind: "doc", sizeBytes: 0.8 * MB, bytesPerTick: 0.05 * MB },
{ name: "analytics-dashboard.png", kind: "image", sizeBytes: 2.2 * MB, bytesPerTick: 0.1 * MB },
];
function IconCloudUp({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<path d="M7 16a4 4 0 0 1-.75-7.93 5 5 0 0 1 9.68 1.36A3.5 3.5 0 0 1 17 16" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12 20v-8M9 14.5 12 11.5l3 3" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconImage({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="3.5" stroke="currentColor" strokeWidth="1.7" />
<circle cx="8.5" cy="8.5" r="1.7" fill="currentColor" />
<path d="M4 16.5 8.5 12l3.5 3 3-2.5L20 16" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconVideo({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="3.5" stroke="currentColor" strokeWidth="1.7" />
<path d="M10 9.3 15 12l-5 2.7V9.3Z" fill="currentColor" />
</svg>
);
}
function IconFileLines({ className, lines }: IconProps & { lines: number }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<path d="M7 3h7l4 4v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
<path d="M14 3v4h4" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
{Array.from({ length: lines }).map((_, i) => (
<path key={i} d={`M9 ${12 + i * 3}h${i === lines - 1 ? 3 : 6}`} stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
))}
</svg>
);
}
function IconZip({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<rect x="4" y="3" width="16" height="18" rx="3" stroke="currentColor" strokeWidth="1.7" />
<path d="M12 3v2M12 6.5v1.5M12 9.5v1.5M12 12.5v1.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
<rect x="10.4" y="14.5" width="3.2" height="3.6" rx="1" stroke="currentColor" strokeWidth="1.5" />
</svg>
);
}
function KindIcon({ kind, className }: { kind: FileKind; className?: string }) {
switch (kind) {
case "image":
return <IconImage className={className} />;
case "video":
return <IconVideo className={className} />;
case "zip":
return <IconZip className={className} />;
case "pdf":
return <IconFileLines className={className} lines={2} />;
case "doc":
return <IconFileLines className={className} lines={3} />;
default:
return <IconFileLines className={className} lines={3} />;
}
}
function IconPause({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<rect x="7" y="6" width="3.2" height="12" rx="1.2" fill="currentColor" />
<rect x="13.8" y="6" width="3.2" height="12" rx="1.2" fill="currentColor" />
</svg>
);
}
function IconPlay({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<path d="M8 6.2v11.6a1 1 0 0 0 1.52.85l9.2-5.8a1 1 0 0 0 0-1.7l-9.2-5.8A1 1 0 0 0 8 6.2Z" fill="currentColor" />
</svg>
);
}
function IconX({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<path d="m7 7 10 10M17 7 7 17" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" />
</svg>
);
}
function IconRetry({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<path d="M20 12a8 8 0 1 1-2.3-5.6" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" />
<path d="M20 4v4.2h-4.2" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconCheck({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<path d="m5 12.5 4.5 4.5L19 7" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconAlert({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<path d="M12 4.5 21 19.5H3L12 4.5Z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
<path d="M12 10v4" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" />
<path d="M12 16.6h.01" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" />
</svg>
);
}
function IconSpinner({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.6" opacity="0.25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" />
</svg>
);
}
const ICON_BTN =
"inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900";
function FileRow({
file,
reduce,
onToggle,
onRetry,
onCancel,
}: {
file: UploadFile;
reduce: boolean;
onToggle: (id: string) => void;
onRetry: (id: string) => void;
onCancel: (id: string) => void;
}) {
const { name, kind, sizeBytes, uploadedBytes, bytesPerTick, status } = file;
const pct = Math.min(100, Math.round((uploadedBytes / sizeBytes) * 100));
const remaining = Math.max(0, sizeBytes - uploadedBytes);
const meta = KIND_META[kind];
const fillClass =
status === "done"
? "bg-emerald-500"
: status === "error"
? "bg-rose-500"
: status === "paused"
? "bg-amber-500"
: "bg-gradient-to-r from-indigo-500 to-violet-500";
let statusNode: ReactNode;
if (status === "uploading") {
statusNode = (
<span className="flex items-center gap-1.5 text-slate-500 dark:text-slate-400">
<IconSpinner className={`size-3.5 text-indigo-500 dark:text-indigo-400 ${reduce ? "" : "pu_spin"}`} />
<span className="tabular-nums">{formatSpeed(bytesPerTick)}</span>
<span aria-hidden="true">·</span>
<span className="tabular-nums">{formatEta(remaining, bytesPerTick)}</span>
</span>
);
} else if (status === "paused") {
statusNode = <span className="font-medium text-amber-600 dark:text-amber-400">Paused</span>;
} else if (status === "done") {
statusNode = <span className="font-medium text-emerald-600 dark:text-emerald-400">Upload complete</span>;
} else {
statusNode = <span className="font-medium text-rose-600 dark:text-rose-400">Upload failed — network error</span>;
}
const rightMeta =
status === "done" || status === "error"
? formatBytes(sizeBytes)
: `${formatBytes(uploadedBytes)} / ${formatBytes(sizeBytes)}`;
return (
<li className="flex items-center gap-3 px-4 py-4 sm:gap-4 sm:px-6">
<span className={`flex size-10 shrink-0 items-center justify-center rounded-xl ${meta.chipBg} ${meta.chipFg}`}>
<KindIcon kind={kind} className="size-5" />
<span className="sr-only">{meta.label}</span>
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<p className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">{name}</p>
<span className="shrink-0 text-xs tabular-nums text-slate-400 dark:text-slate-500">{rightMeta}</span>
</div>
<div
className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
aria-label={`${name} upload progress`}
>
<div
className={`relative h-full overflow-hidden rounded-full ${fillClass}`}
style={{ width: `${pct}%`, transition: reduce ? "none" : "width 320ms cubic-bezier(0.22,1,0.36,1)" }}
>
{status === "uploading" && !reduce && (
<span className="pu_shimmer pointer-events-none absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/50 to-transparent" />
)}
</div>
</div>
<div className="mt-1.5 flex items-center justify-between gap-3 text-xs">
{statusNode}
<span className="shrink-0 tabular-nums font-semibold text-slate-600 dark:text-slate-300">{pct}%</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-0.5 self-start">
{status === "done" && (
<span className="flex size-8 items-center justify-center rounded-lg text-emerald-500" aria-hidden="true">
<IconCheck className="size-5" />
</span>
)}
{(status === "uploading" || status === "paused") && (
<button
type="button"
onClick={() => onToggle(file.id)}
className={ICON_BTN}
aria-label={status === "uploading" ? `Pause upload of ${name}` : `Resume upload of ${name}`}
>
{status === "uploading" ? <IconPause className="size-4" /> : <IconPlay className="size-4" />}
</button>
)}
{status === "error" && (
<button type="button" onClick={() => onRetry(file.id)} className={ICON_BTN} aria-label={`Retry upload of ${name}`}>
<IconRetry className="size-4" />
</button>
)}
<button type="button" onClick={() => onCancel(file.id)} className={ICON_BTN} aria-label={`Remove ${name} from queue`}>
<IconX className="size-4" />
</button>
</div>
</li>
);
}
export default function ProgUpload() {
const reduce = !!useReducedMotion();
const [files, setFiles] = useState<UploadFile[]>(SEED_FILES);
const addCount = useRef(0);
useEffect(() => {
const id = window.setInterval(() => {
setFiles((prev) => {
let changed = false;
const next = prev.map((f) => {
if (f.status !== "uploading") return f;
changed = true;
const jitter = 0.82 + Math.random() * 0.36;
const up = Math.min(f.sizeBytes, f.uploadedBytes + f.bytesPerTick * jitter);
const status: UploadStatus = up >= f.sizeBytes ? "done" : "uploading";
return { ...f, uploadedBytes: up, status };
});
return changed ? next : prev;
});
}, TICK_MS);
return () => window.clearInterval(id);
}, []);
function toggle(fileId: string) {
setFiles((prev) =>
prev.map((f) => {
if (f.id !== fileId) return f;
if (f.status === "uploading") return { ...f, status: "paused" };
if (f.status === "paused") return { ...f, status: "uploading" };
return f;
}),
);
}
function retry(fileId: string) {
setFiles((prev) => prev.map((f) => (f.id === fileId ? { ...f, uploadedBytes: 0, status: "uploading" } : f)));
}
function cancel(fileId: string) {
setFiles((prev) => prev.filter((f) => f.id !== fileId));
}
function addFile() {
const spec = FILE_POOL[addCount.current % FILE_POOL.length];
addCount.current += 1;
const nextFile: UploadFile = {
id: `added-${addCount.current}-${Math.round(Math.random() * 1e6)}`,
name: spec.name,
kind: spec.kind,
sizeBytes: spec.sizeBytes,
uploadedBytes: 0,
bytesPerTick: spec.bytesPerTick,
status: "uploading",
};
setFiles((prev) => [...prev, nextFile]);
}
function toggleAll() {
setFiles((prev) => {
const anyUp = prev.some((f) => f.status === "uploading");
return prev.map((f) => {
if (anyUp && f.status === "uploading") return { ...f, status: "paused" };
if (!anyUp && f.status === "paused") return { ...f, status: "uploading" };
return f;
});
});
}
function clearCompleted() {
setFiles((prev) => prev.filter((f) => f.status !== "done"));
}
const active = files.filter((f) => f.status !== "error");
const totalBytes = active.reduce((s, f) => s + f.sizeBytes, 0);
const uploadedTotal = active.reduce((s, f) => s + f.uploadedBytes, 0);
const overallPct = totalBytes ? Math.round((uploadedTotal / totalBytes) * 100) : 0;
const doneCount = files.filter((f) => f.status === "done").length;
const uploadingCount = files.filter((f) => f.status === "uploading").length;
const pausedCount = files.filter((f) => f.status === "paused").length;
const errorCount = files.filter((f) => f.status === "error").length;
const hasCompleted = doneCount > 0;
const canToggleAll = uploadingCount > 0 || pausedCount > 0;
const anyUploading = uploadingCount > 0;
const subtitleParts: string[] = [];
if (uploadingCount) subtitleParts.push(`${uploadingCount} uploading`);
if (pausedCount) subtitleParts.push(`${pausedCount} paused`);
if (doneCount) subtitleParts.push(`${doneCount} done`);
if (errorCount) subtitleParts.push(`${errorCount} failed`);
const subtitle = files.length === 0 ? "Your queue is empty" : subtitleParts.join(" · ");
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:py-24 dark:bg-slate-950">
<style>{`
.pu_shimmer { animation: pu_shimmer 1.5s ease-in-out infinite; }
.pu_spin { animation: pu_spin 0.8s linear infinite; }
@keyframes pu_shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(220%); } }
@keyframes pu_spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.pu_shimmer, .pu_spin { animation: none !important; }
}
`}</style>
<div
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] max-w-full -translate-x-1/2 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-600/15"
aria-hidden="true"
/>
<div className="relative mx-auto w-full max-w-2xl">
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900">
<div className="p-6 sm:p-8">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="text-lg font-semibold tracking-tight text-slate-900 dark:text-slate-50">Uploading to Workspace</h2>
<p className="mt-1 truncate text-sm text-slate-500 dark:text-slate-400">{subtitle}</p>
</div>
<span className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-indigo-50 text-indigo-600 ring-1 ring-inset ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-400 dark:ring-indigo-500/20">
<IconCloudUp className="size-6" />
</span>
</div>
<button
type="button"
onClick={addFile}
className="group mt-6 flex w-full flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-slate-300 bg-slate-50/60 px-6 py-7 text-center transition-colors hover:border-indigo-400 hover:bg-indigo-50/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800/40 dark:hover:border-indigo-500 dark:hover:bg-indigo-500/5 dark:focus-visible:ring-offset-slate-900"
>
<IconCloudUp className="size-7 text-slate-400 transition-colors group-hover:text-indigo-500 dark:text-slate-500 dark:group-hover:text-indigo-400" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
Drop files or <span className="text-indigo-600 dark:text-indigo-400">browse</span>
</span>
<span className="text-xs text-slate-400 dark:text-slate-500">Adds a file to the queue · PNG, MP4, ZIP up to 2 GB</span>
</button>
<div className="mt-6">
<div className="mb-2 flex items-center justify-between text-xs font-medium">
<span className="text-slate-500 dark:text-slate-400">Overall progress</span>
<span className="tabular-nums text-slate-900 dark:text-slate-200">{overallPct}%</span>
</div>
<div
className="h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={overallPct}
aria-label="Overall upload progress"
>
<div
className="h-full rounded-full bg-gradient-to-r from-indigo-500 via-violet-500 to-indigo-500"
style={{ width: `${overallPct}%`, transition: reduce ? "none" : "width 320ms cubic-bezier(0.22,1,0.36,1)" }}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between border-t border-slate-100 px-4 py-3 sm:px-6 dark:border-slate-800">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">Queue · {files.length}</h3>
<button
type="button"
onClick={toggleAll}
disabled={!canToggleAll}
className="rounded-lg px-2.5 py-1 text-xs font-semibold text-indigo-600 transition-colors hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:bg-transparent dark:text-indigo-400 dark:hover:bg-indigo-500/10 dark:focus-visible:ring-offset-slate-900 dark:disabled:text-slate-600"
>
{anyUploading ? "Pause all" : "Resume all"}
</button>
</div>
{files.length === 0 ? (
<div className="px-6 py-14 text-center">
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">No files in the queue</p>
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">Use the drop zone above to add a file.</p>
</div>
) : (
<ul role="list" className="divide-y divide-slate-100 dark:divide-slate-800">
{files.map((file) => (
<FileRow key={file.id} file={file} reduce={reduce} onToggle={toggle} onRetry={retry} onCancel={cancel} />
))}
</ul>
)}
<div className="flex items-center justify-between gap-3 border-t border-slate-100 px-4 py-3.5 sm:px-6 dark:border-slate-800">
<p className="text-xs text-slate-500 tabular-nums dark:text-slate-400">
{doneCount} of {files.length} uploaded
</p>
<button
type="button"
onClick={clearCompleted}
disabled={!hasCompleted}
className="rounded-lg px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:bg-transparent dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900 dark:disabled:text-slate-600"
>
Clear completed
</button>
</div>
</div>
<p className="sr-only" aria-live="polite">
{doneCount} of {files.length} files uploaded, {overallPct} percent complete.
</p>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Bar Basic Progress
Originalbasic progress bars in colours

Bar Striped Progress
Originalanimated striped progress bar

Bar Gradient Progress
Originalgradient progress bar with label

Bar Steps Progress
Originalstepped progress bar

Ring Progress
Originalcircular progress ring with percent

Ring Gradient Progress
Originalgradient circular progress

Semicircle Progress
Originalsemicircle gauge progress

Multi Progress
Originalmulti-segment stacked progress

Indeterminate Progress
Originalindeterminate progress bar

Label Inside Progress
Originalprogress bar with inner label

Skill Bars Progress
Originalanimated skill/percent bars

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

