Web InnoventixFreeCode

Download Progress Button

Original · free

A download button that fills with progress on click.

byWeb InnoventixReact + Tailwind
btndownloadprogressbuttons
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/btn-download-progress.json
btn-download-progress.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Phase = "idle" | "downloading" | "done" | "error";

type DownloadFile = {
  name: string;
  size: string;
  duration: number;
};

function useSimulatedDownload(file: DownloadFile) {
  const [phase, setPhase] = useState<Phase>("idle");
  const [progress, setProgress] = useState(0);
  const rafRef = useRef<number | null>(null);
  const startRef = useRef<number>(0);
  const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const clear = useCallback(() => {
    if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
    if (resetRef.current !== null) clearTimeout(resetRef.current);
    rafRef.current = null;
    resetRef.current = null;
  }, []);

  useEffect(() => clear, [clear]);

  const start = useCallback(() => {
    if (phase === "downloading") return;
    clear();
    setPhase("downloading");
    setProgress(0);
    startRef.current = performance.now();

    const tick = (now: number) => {
      const elapsed = now - startRef.current;
      const pct = Math.min(100, (elapsed / file.duration) * 100);
      setProgress(pct);
      if (pct >= 100) {
        setPhase("done");
        resetRef.current = setTimeout(() => {
          setPhase("idle");
          setProgress(0);
        }, 2200);
        return;
      }
      rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
  }, [phase, file.duration, clear]);

  const reset = useCallback(() => {
    clear();
    setPhase("idle");
    setProgress(0);
  }, [clear]);

  return { phase, progress, start, reset };
}

function DownloadGlyph({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M12 3v12" />
      <path d="m7 10 5 5 5-5" />
      <path d="M4 20h16" />
    </svg>
  );
}

function CheckGlyph({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2.5}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="m4 12.5 5 5 11-12" />
    </svg>
  );
}

function Spinner({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      className={`bdp-spin ${className ?? ""}`}
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
      <path
        d="M21 12a9 9 0 0 0-9-9"
        stroke="currentColor"
        strokeWidth="3"
        strokeLinecap="round"
      />
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* Variant 1 — Fill button: background fills left→right with progress  */
/* ------------------------------------------------------------------ */

function FillButton({ file }: { file: DownloadFile }) {
  const { phase, progress, start } = useSimulatedDownload(file);
  const rounded = Math.round(progress);
  const busy = phase === "downloading";

  const label =
    phase === "done"
      ? "Downloaded"
      : busy
        ? `Downloading ${rounded}%`
        : `Download ${file.name}`;

  return (
    <button
      type="button"
      onClick={start}
      disabled={busy}
      aria-busy={busy}
      aria-live="polite"
      className="group relative isolate flex h-12 w-72 items-center justify-center gap-2 overflow-hidden rounded-xl border border-indigo-600/70 bg-indigo-600 px-5 text-sm font-semibold text-white shadow-sm shadow-indigo-600/20 outline-none transition-transform duration-150 active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-progress dark:border-indigo-400/40 dark:focus-visible:ring-offset-slate-950"
    >
      <span
        className="absolute inset-y-0 left-0 z-0 bg-gradient-to-r from-indigo-400 to-violet-400 transition-[width] duration-150 ease-linear dark:from-indigo-500 dark:to-violet-500"
        style={{ width: `${phase === "done" ? 100 : progress}%` }}
        aria-hidden="true"
      />
      <span className="relative z-10 flex items-center gap-2">
        {phase === "done" ? (
          <CheckGlyph className="h-4 w-4" />
        ) : busy ? (
          <Spinner className="h-4 w-4" />
        ) : (
          <DownloadGlyph className="h-4 w-4 transition-transform duration-200 group-hover:translate-y-0.5" />
        )}
        <span className="tabular-nums">{label}</span>
      </span>
    </button>
  );
}

/* ------------------------------------------------------------------ */
/* Variant 2 — Ring button: circular icon button with SVG progress ring */
/* ------------------------------------------------------------------ */

function RingButton({ file }: { file: DownloadFile }) {
  const { phase, progress, start } = useSimulatedDownload(file);
  const busy = phase === "downloading";
  const R = 20;
  const C = 2 * Math.PI * R;
  const offset = C - (progress / 100) * C;

  const aria =
    phase === "done"
      ? `${file.name} downloaded`
      : busy
        ? `Downloading ${file.name}, ${Math.round(progress)} percent`
        : `Download ${file.name}`;

  return (
    <div className="flex flex-col items-center gap-2">
      <button
        type="button"
        onClick={start}
        disabled={busy}
        aria-busy={busy}
        aria-label={aria}
        className="group relative grid h-16 w-16 place-items-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm outline-none transition-transform duration-150 active:scale-95 focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-progress dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:focus-visible:ring-offset-slate-950"
      >
        <svg viewBox="0 0 48 48" className="absolute inset-0 h-full w-full -rotate-90" aria-hidden="true">
          <circle
            cx="24"
            cy="24"
            r={R}
            fill="none"
            strokeWidth="3"
            className="stroke-slate-200 dark:stroke-slate-700"
          />
          <circle
            cx="24"
            cy="24"
            r={R}
            fill="none"
            strokeWidth="3"
            strokeLinecap="round"
            strokeDasharray={C}
            strokeDashoffset={phase === "done" ? 0 : offset}
            className="stroke-emerald-500 transition-[stroke-dashoffset] duration-150 ease-linear dark:stroke-emerald-400"
          />
        </svg>
        <span className="relative">
          {phase === "done" ? (
            <CheckGlyph className="h-6 w-6 text-emerald-500 dark:text-emerald-400" />
          ) : busy ? (
            <span className="text-xs font-bold tabular-nums text-slate-700 dark:text-slate-200">
              {Math.round(progress)}
            </span>
          ) : (
            <DownloadGlyph className="h-6 w-6 transition-transform duration-200 group-hover:translate-y-0.5" />
          )}
        </span>
      </button>
      <span className="text-xs font-medium text-slate-500 dark:text-slate-400">{file.size}</span>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Variant 3 — Bar button: label + detached track bar underneath       */
/* ------------------------------------------------------------------ */

function BarButton({ file }: { file: DownloadFile }) {
  const reduce = useReducedMotion();
  const { phase, progress, start } = useSimulatedDownload(file);
  const busy = phase === "downloading";
  const rounded = Math.round(progress);

  return (
    <button
      type="button"
      onClick={start}
      disabled={busy}
      aria-busy={busy}
      className="group flex w-80 flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm outline-none transition-colors hover:border-slate-300 focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-progress dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700 dark:focus-visible:ring-offset-slate-950"
    >
      <span className="flex items-center gap-3">
        <span
          className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl transition-colors ${
            phase === "done"
              ? "bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400"
              : "bg-violet-100 text-violet-600 dark:bg-violet-500/15 dark:text-violet-400"
          }`}
        >
          {phase === "done" ? (
            <CheckGlyph className="h-5 w-5" />
          ) : busy ? (
            <Spinner className="h-5 w-5" />
          ) : (
            <DownloadGlyph className="h-5 w-5 transition-transform duration-200 group-hover:translate-y-0.5" />
          )}
        </span>
        <span className="min-w-0 flex-1">
          <span className="block truncate text-sm font-semibold text-slate-800 dark:text-slate-100">
            {file.name}
          </span>
          <span className="block text-xs text-slate-500 dark:text-slate-400">
            {phase === "done"
              ? "Saved to your device"
              : busy
                ? `${rounded}% of ${file.size}`
                : `${file.size} · click to download`}
          </span>
        </span>
      </span>

      <span
        className="relative 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={rounded}
      >
        <span
          className={`absolute inset-y-0 left-0 rounded-full ${
            phase === "done"
              ? "bg-emerald-500 dark:bg-emerald-400"
              : "bg-gradient-to-r from-violet-500 to-fuchsia-500 dark:from-violet-400 dark:to-fuchsia-400"
          } ${reduce ? "" : "transition-[width] duration-150 ease-linear"}`}
          style={{ width: `${phase === "done" ? 100 : progress}%` }}
        />
      </span>
    </button>
  );
}

/* ------------------------------------------------------------------ */
/* Variant 4 — Morph button: pill collapses to circle then reveals ✓   */
/* ------------------------------------------------------------------ */

function MorphButton({ file }: { file: DownloadFile }) {
  const reduce = useReducedMotion();
  const { phase, progress, start } = useSimulatedDownload(file);
  const busy = phase === "downloading";

  return (
    <button
      type="button"
      onClick={start}
      disabled={busy}
      aria-busy={busy}
      aria-label={
        phase === "done"
          ? `${file.name} downloaded`
          : busy
            ? `Downloading ${Math.round(progress)} percent`
            : `Download ${file.name}`
      }
      className={`group relative flex h-12 items-center justify-center overflow-hidden border text-sm font-semibold text-white outline-none transition-all duration-300 ease-out active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-rose-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-progress dark:focus-visible:ring-offset-slate-950 ${
        busy || phase === "done"
          ? "w-12 rounded-full border-rose-500 bg-rose-500 dark:border-rose-400 dark:bg-rose-500"
          : "w-56 rounded-xl border-slate-900 bg-slate-900 dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900"
      }`}
    >
      {busy && (
        <svg viewBox="0 0 48 48" className="absolute inset-0 h-full w-full -rotate-90" aria-hidden="true">
          <circle
            cx="24"
            cy="24"
            r="21"
            fill="none"
            strokeWidth="3"
            className="stroke-white/25"
          />
          <circle
            cx="24"
            cy="24"
            r="21"
            fill="none"
            strokeWidth="3"
            strokeLinecap="round"
            strokeDasharray={2 * Math.PI * 21}
            strokeDashoffset={2 * Math.PI * 21 - (progress / 100) * (2 * Math.PI * 21)}
            className={`stroke-white ${reduce ? "" : "transition-[stroke-dashoffset] duration-150 ease-linear"}`}
          />
        </svg>
      )}
      <AnimatePresence mode="wait" initial={false}>
        {phase === "done" ? (
          <motion.span
            key="done"
            initial={reduce ? false : { scale: 0.4, opacity: 0 }}
            animate={{ scale: 1, opacity: 1 }}
            exit={reduce ? undefined : { scale: 0.4, opacity: 0 }}
            transition={{ duration: 0.25 }}
          >
            <CheckGlyph className="h-5 w-5" />
          </motion.span>
        ) : busy ? (
          <motion.span
            key="busy"
            initial={false}
            className="text-xs font-bold tabular-nums"
          >
            {Math.round(progress)}
          </motion.span>
        ) : (
          <motion.span
            key="idle"
            initial={reduce ? false : { opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={reduce ? undefined : { opacity: 0 }}
            className="flex items-center gap-2"
          >
            <DownloadGlyph className="h-4 w-4 transition-transform duration-200 group-hover:translate-y-0.5" />
            Download {file.name}
          </motion.span>
        )}
      </AnimatePresence>
    </button>
  );
}

export default function BtnDownloadProgress() {
  return (
    <section className="relative w-full bg-white px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-10 sm:py-24">
      <style>{`
        @keyframes bdp-spin { to { transform: rotate(360deg); } }
        .bdp-spin { animation: bdp-spin 0.8s linear infinite; transform-origin: center; }
        @media (prefers-reduced-motion: reduce) {
          .bdp-spin { animation: none; }
        }
      `}</style>

      <div className="mx-auto flex max-w-4xl flex-col items-center">
        <span className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
          <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
          Download buttons
        </span>
        <h2 className="mt-5 text-center text-3xl font-bold tracking-tight sm:text-4xl">
          Progress that fills as it downloads
        </h2>
        <p className="mt-3 max-w-xl text-center text-sm leading-relaxed text-slate-500 dark:text-slate-400">
          Four tactile takes on the same idea — a button that shows real progress on click. Fully
          keyboard accessible, with loading and done states.
        </p>

        <div className="mt-14 grid w-full grid-cols-1 gap-x-8 gap-y-14 sm:grid-cols-2">
          <div className="flex flex-col items-center gap-4">
            <FillButton file={{ name: "Report.pdf", size: "4.2 MB", duration: 2600 }} />
            <span className="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">
              Fill
            </span>
          </div>

          <div className="flex flex-col items-center gap-4">
            <RingButton file={{ name: "assets.zip", size: "18 MB", duration: 3200 }} />
            <span className="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">
              Ring
            </span>
          </div>

          <div className="flex flex-col items-center gap-4">
            <BarButton file={{ name: "Q3-Financials.xlsx", size: "1.1 MB", duration: 2200 }} />
            <span className="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">
              Bar
            </span>
          </div>

          <div className="flex flex-col items-center gap-4">
            <MorphButton file={{ name: "invoice", size: "220 KB", duration: 2400 }} />
            <span className="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">
              Morph
            </span>
          </div>
        </div>
      </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 →