Web InnoventixFreeCode

Upload Progress

Original · free

file upload progress rows

byWeb InnoventixReact + Tailwind
proguploadprogress
Open

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.json
prog-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 quote

Similar components

Browse all →