Download Progress Button
Original · freeA download button that fills with progress on click.
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"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
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 →
Shimmer Button
MITA dark pill button with a continuous conic-gradient light shimmer sweeping around its border.

Rainbow Gradient Button
MITA button wrapped in an animated multi-colour gradient border with a matching blurred underglow.

Interactive Hover Button
MITA pill button whose dot expands to fill the surface and reveals an arrow label on hover.

Pulsating Button
MITA solid button that emits a soft, continuously pulsing glow ring to draw the eye to the primary action.

Shiny Button
MITA frosted-glass button with a spring-animated light sweeping across its text and border.

Solid Set Button
OriginalA set of solid buttons: primary, secondary, success and danger, in three sizes.

Outline Set Button
OriginalOutline button variants across colours and sizes with a hover fill.

Ghost Set Button
OriginalGhost and text buttons with subtle hover backgrounds.

Soft Set Button
OriginalSoft, tinted buttons across semantic colours.

Gradient Set Button
OriginalGradient-filled buttons with a hover shift.
Icon Leading Button
OriginalButtons with leading and trailing icons.
Icon Only Button
OriginalSquare and circular icon-only buttons with tooltips and aria-labels.

