"use client";
import * as React from "react";
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from "motion/react";
export type ProgressButtonStatus = "idle" | "running" | "paused" | "complete" | "error";
/**
* Reports progress (0–1) from an `onStart` task. The returned promise stays pending while the user has
* paused, and rejects once they cancel — `await` it inside your loop to honour pause and cancel.
*/
export type ProgressReport = (progress: number) => Promise<void>;
type Spring = { stiffness: number; damping: number; mass: number };
export interface ProgressButtonProps
extends Omit<
React.ComponentPropsWithoutRef<"button">,
| "children"
| "onDrag"
| "onDragStart"
| "onDragEnd"
| "onAnimationStart"
| "onAnimationEnd"
| "onPause"
| "onCancel"
| "onError"
> {
/** Idle label, also the base of the accessible names ("Pause Download", "Cancel Download"). */
label?: string;
/** Visible label while running. */
runningLabel?: string;
/** Visible label while paused. */
pausedLabel?: string;
/** Visible and announced label once complete. */
completeLabel?: string;
/** Visible and announced label on failure (the button retries when pressed). */
errorLabel?: string;
/** Controlled progress, 0–1. */
progress?: number;
/**
* Controlled status. When set, presses only *request* a new status through `onStatusChange`;
* you drive `progress` and `status` yourself and `onStart` is not called.
*/
status?: ProgressButtonStatus;
/**
* Uncontrolled mode: the task to run on press (and on retry). Call `report(0..1)` as it advances;
* resolve to complete, reject to fail. `signal` aborts when the user cancels or the button unmounts.
*/
onStart?: (report: ProgressReport, signal: AbortSignal) => Promise<unknown> | unknown;
/** Called on every status change (uncontrolled) or status request (controlled). */
onStatusChange?: (status: ProgressButtonStatus) => void;
onPause?: () => void;
onResume?: () => void;
onCancel?: () => void;
onComplete?: () => void;
/** Receives the rejection reason when `onStart` fails. */
onError?: (error: unknown) => void;
/** Allow pausing by pressing the button while it runs. */
pausable?: boolean;
/** Show a cancel button (and let Escape cancel) while running or paused. */
cancellable?: boolean;
/** Time in ms before "complete" returns to idle (uncontrolled). 0 keeps it complete. */
resetDelay?: number;
/** `fill` sweeps a solid fill across the button; `bar` draws a hairline progress bar. */
variant?: "fill" | "bar";
/** Fill, bar and focus-ring colour. */
accent?: string;
/** Text colour on top of the fill. */
inkColor?: string;
/** Fill colour in the error state. */
errorColor?: string;
/** Spring that eases the fill towards each reported value. */
spring?: Spring;
/** Width of the main button (any CSS length). It shrinks to fit narrow containers. */
width?: number | string;
/** Optional icon shown before the idle label (replaces the download glyph). */
icon?: React.ReactNode;
}
/* --------------------------------- helpers --------------------------------- */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const clamp01 = (n: number) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0);
const noop = () => {};
const EASE = [0.22, 1, 0.36, 1] as const;
interface Gate {
promise: Promise<void>;
resolve: () => void;
reject: (reason: unknown) => void;
}
/** A promise that `report()` hands out while paused. */
function createGate(): Gate {
let resolve: () => void = noop;
let reject: (reason: unknown) => void = noop;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
// Mark as handled: callers that never await report() must not see an unhandled rejection on cancel.
promise.catch(noop);
return { promise, resolve, reject };
}
/* ---------------------------------- glyphs --------------------------------- */
const glyph = "size-4 shrink-0";
function Glyph({ kind, instant }: { kind: "download" | "pause" | "play" | "check" | "retry" | "close"; instant?: boolean }) {
const common = { fill: "none", stroke: "currentColor", strokeWidth: 1.75, strokeLinecap: "round", strokeLinejoin: "round" } as const;
switch (kind) {
case "download":
return (
<svg viewBox="0 0 16 16" className={glyph} aria-hidden="true" {...common}>
<path d="M8 2.5v7.5M4.75 7l3.25 3.25L11.25 7M3 13h10" />
</svg>
);
case "pause":
return (
<svg viewBox="0 0 16 16" className={glyph} aria-hidden="true" {...common}>
<path d="M5.75 3.5v9M10.25 3.5v9" />
</svg>
);
case "play":
return (
<svg viewBox="0 0 16 16" className={glyph} aria-hidden="true" {...common}>
<path d="M5 3.4v9.2l7.4-4.6z" />
</svg>
);
case "retry":
return (
<svg viewBox="0 0 16 16" className={glyph} aria-hidden="true" {...common}>
<path d="M12.9 8a4.9 4.9 0 1 1-1.45-3.48M12.9 2.6v2.6h-2.6" />
</svg>
);
case "close":
return (
<svg viewBox="0 0 16 16" className="size-3.5 shrink-0" aria-hidden="true" {...common}>
<path d="M4.25 4.25l7.5 7.5M11.75 4.25l-7.5 7.5" />
</svg>
);
case "check":
return (
<svg viewBox="0 0 16 16" className={glyph} aria-hidden="true" {...common}>
<motion.path
d="M3.5 8.5l3 3 6-7"
initial={{ pathLength: instant ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.32, delay: instant ? 0 : 0.1, ease: EASE }}
/>
</svg>
);
}
}
const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
/** A single odometer wheel; the CSS transition rolls it from its previous digit. */
function DigitWheel({ digit, instant }: { digit: number; instant: boolean }) {
return (
<span className="relative inline-block h-[1.2em] overflow-hidden leading-[1.2em]">
<span className="invisible">0</span>
<span
className="absolute inset-x-0 top-0 flex flex-col items-center transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ transform: `translateY(${-digit * 1.2}em)`, transitionDuration: instant ? "0ms" : undefined }}
>
{DIGITS.map((d) => (
<span key={d} className="h-[1.2em]">
{d}
</span>
))}
</span>
</span>
);
}
/** Percentage whose digits roll. Wheels are keyed from the right so 9 → 10 keeps the ones wheel. */
function RollingPercent({ value, instant }: { value: number; instant: boolean }) {
const chars = String(value).split("");
return (
<span className="inline-flex items-baseline font-mono text-[12px] tabular-nums tracking-tight">
{chars.map((c, i) => (
<DigitWheel key={`d${chars.length - i}`} digit={Number(c)} instant={instant} />
))}
<span className="ml-px opacity-70">%</span>
</span>
);
}
/* --------------------------------- component -------------------------------- */
/**
* A download / upload button whose fill sweeps across as work progresses. Press to start, press again
* to pause or resume, cancel with the side button (or Escape), retry after an error. Drive it with
* `progress` + `status`, or hand it an async `onStart(report, signal)` and it manages everything.
*/
export const ProgressButton = React.forwardRef<HTMLButtonElement, ProgressButtonProps>(function ProgressButton(
{
label = "Download",
runningLabel = "Downloading",
pausedLabel = "Paused",
completeLabel = "Downloaded",
errorLabel = "Failed · Retry",
progress: progressProp,
status: statusProp,
onStart,
onStatusChange,
onPause,
onResume,
onCancel,
onComplete,
onError,
pausable = true,
cancellable = true,
resetDelay = 2400,
variant = "fill",
accent = "#FF7448",
inkColor = "#0B0D0F",
errorColor = "#F87171",
spring = { stiffness: 140, damping: 24, mass: 0.6 },
width = "14rem",
icon,
className = "",
style,
onClick,
onKeyDown,
...props
},
ref,
) {
const reduceMotion = usePrefersReducedMotion();
const controlled = statusProp !== undefined;
const [internalStatus, setInternalStatus] = React.useState<ProgressButtonStatus>("idle");
const [internalProgress, setInternalProgress] = React.useState(0);
const [cancelled, setCancelled] = React.useState(false);
const status = statusProp ?? internalStatus;
const progress = clamp01(progressProp ?? (controlled ? (status === "complete" ? 1 : 0) : internalProgress));
const pct = Math.round(progress * 100);
const mainRef = React.useRef<HTMLButtonElement>(null);
React.useImperativeHandle(ref, () => mainRef.current as HTMLButtonElement, []);
const controllerRef = React.useRef<AbortController | null>(null);
const gateRef = React.useRef<Gate | null>(null);
const resetTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const progressId = React.useId();
const descriptionId = React.useId();
// Abort the running task and clear timers on unmount.
React.useEffect(
() => () => {
clearTimeout(resetTimer.current);
controllerRef.current?.abort();
gateRef.current?.reject(new DOMException("Unmounted", "AbortError"));
},
[],
);
const setStatus = (next: ProgressButtonStatus) => {
if (!controlled) setInternalStatus(next);
onStatusChange?.(next);
};
/* ------------------------------ fill animation ----------------------------- */
const fill = useMotionValue(0);
const { stiffness, damping, mass } = spring;
const target = status === "idle" ? 0 : status === "complete" ? 1 : progress;
React.useEffect(() => {
if (reduceMotion) {
fill.set(target);
return;
}
// Drain quickly on reset / cancel; otherwise ease towards the reported value with the spring.
const controls = animate(
fill,
target,
target < fill.get() ? { duration: 0.45, ease: EASE } : { type: "spring", stiffness, damping, mass },
);
return () => controls.stop();
}, [fill, target, reduceMotion, stiffness, damping, mass]);
const clipPath = useTransform(fill, (v) => `inset(0 ${((1 - v) * 100).toFixed(2)}% 0 0)`);
const edgeLeft = useTransform(fill, (v) => `${(v * 100).toFixed(2)}%`);
const edgeOpacity = useTransform(fill, (v) => (v > 0.002 && v < 0.998 ? 1 : 0));
/* --------------------------------- actions --------------------------------- */
const start = () => {
setCancelled(false);
if (controlled) {
setStatus("running");
return;
}
if (!onStart) return;
clearTimeout(resetTimer.current);
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
gateRef.current = null;
setInternalProgress(0);
setStatus("running");
const isCurrent = () => controllerRef.current === controller && !controller.signal.aborted;
const report: ProgressReport = (value) => {
if (!isCurrent()) {
const stopped = Promise.reject(controller.signal.reason ?? new DOMException("Aborted", "AbortError"));
stopped.catch(noop);
return stopped;
}
setInternalProgress(clamp01(value));
return gateRef.current?.promise ?? Promise.resolve();
};
Promise.resolve()
.then(() => onStart(report, controller.signal))
.then(
() => {
if (!isCurrent()) return;
gateRef.current = null;
setInternalProgress(1);
setStatus("complete");
onComplete?.();
if (resetDelay > 0) {
resetTimer.current = setTimeout(() => {
if (controllerRef.current !== controller) return;
controllerRef.current = null;
setInternalProgress(0);
setStatus("idle");
}, resetDelay);
}
},
(error: unknown) => {
if (!isCurrent()) return;
gateRef.current = null;
setStatus("error");
onError?.(error);
},
);
};
const pause = () => {
if (!controlled) gateRef.current ??= createGate();
setStatus("paused");
onPause?.();
};
const resume = () => {
gateRef.current?.resolve();
gateRef.current = null;
setStatus("running");
onResume?.();
};
const cancel = () => {
if (status !== "running" && status !== "paused") return;
if (!controlled) {
const controller = controllerRef.current;
controllerRef.current = null;
controller?.abort();
gateRef.current?.reject(controller?.signal.reason ?? new DOMException("Cancelled", "AbortError"));
gateRef.current = null;
setInternalProgress(0);
}
setCancelled(true);
setStatus("idle");
onCancel?.();
// The cancel button is about to disappear; keep focus on the main control.
mainRef.current?.focus();
};
const busy = status === "running" || status === "paused";
const showCancel = cancellable && busy;
const inert = status === "complete" || (status === "running" && !pausable);
/* ------------------------------ accessible text ----------------------------- */
const accessibleName =
status === "running"
? pausable
? `Pause ${label}`
: runningLabel
: status === "paused"
? `Resume ${label}`
: status === "complete"
? completeLabel
: status === "error"
? `Retry ${label}`
: label;
const description =
status === "running"
? `${runningLabel}, ${pct}%`
: status === "paused"
? `${pausedLabel} at ${pct}%`
: status === "error"
? `${errorLabel}, stopped at ${pct}%`
: undefined;
// Milestones only: the live region changes at 25 / 50 / 75 / 100 %, not on every report.
const milestone = pct >= 100 ? 100 : Math.floor(pct / 25) * 25;
const announcement =
status === "running"
? milestone > 0
? `${milestone}%`
: `${runningLabel}…`
: status === "paused"
? `${pausedLabel} at ${pct}%`
: status === "complete"
? completeLabel
: status === "error"
? errorLabel
: cancelled
? `${label} cancelled`
: "";
/* ---------------------------------- visuals --------------------------------- */
const iconFor = (s: ProgressButtonStatus): React.ReactNode => {
if (s === "running") return pausable ? <Glyph kind="pause" /> : <Glyph kind="download" />;
if (s === "paused") return <Glyph kind="play" />;
if (s === "complete") return <Glyph kind="check" instant={reduceMotion} />;
if (s === "error") return <Glyph kind="retry" />;
return icon ? <span className="inline-flex shrink-0 [&_svg]:size-4">{icon}</span> : <Glyph kind="download" />;
};
const textFor = (s: ProgressButtonStatus) =>
s === "running" ? runningLabel : s === "paused" ? pausedLabel : s === "complete" ? completeLabel : s === "error" ? errorLabel : label;
// One face per status, stacked in a single grid cell so the old and new face crossfade in place.
const face = (
<span className="grid w-full grid-cols-1 grid-rows-1 *:col-start-1 *:row-start-1">
<AnimatePresence initial={false}>
<motion.span
key={status}
className={`flex w-full items-center gap-2.5 ${busy ? "justify-between" : "justify-center"}`}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(2px)" }}
animate={reduceMotion ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(2px)" }}
transition={{ duration: reduceMotion ? 0.12 : 0.26, ease: EASE }}
>
<span className="inline-flex min-w-0 items-center gap-2">
{iconFor(status)}
<span className="truncate">{textFor(status)}</span>
</span>
{busy && <RollingPercent value={pct} instant={reduceMotion} />}
</motion.span>
</AnimatePresence>
</span>
);
const fillColor = status === "error" ? errorColor : accent;
const padding = "px-4";
return (
<span
role="group"
aria-label={label}
className="relative inline-flex max-w-full items-center"
style={{ ["--pb-accent" as string]: accent, ["--pb-fill" as string]: fillColor, ["--pb-ink" as string]: inkColor }}
>
<motion.span
className="relative inline-flex min-w-0"
animate={status === "error" && !reduceMotion ? { x: [0, -4, 4, -2, 2, 0] } : { x: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<button
ref={mainRef}
type="button"
aria-label={accessibleName}
aria-describedby={description ? descriptionId : undefined}
aria-disabled={inert || undefined}
aria-busy={status === "running" || undefined}
data-status={status}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || inert) return;
if (status === "idle" || status === "error") start();
else if (status === "running") pause();
else if (status === "paused") resume();
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if (!event.defaultPrevented && event.key === "Escape" && cancellable && busy) {
event.preventDefault();
cancel();
}
}}
className={`group/pb relative isolate inline-flex h-11 min-w-0 max-w-full select-none items-center overflow-hidden rounded-full border border-zinc-900/12 bg-white text-sm font-medium tracking-tight text-zinc-900 shadow-[0_1px_0_rgba(0,0,0,0.04)] outline-hidden transition-[border-color,background-color,box-shadow] duration-200 hover:border-zinc-900/25 focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-solid focus-visible:outline-[var(--pb-accent)] disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:cursor-default dark:border-white/12 dark:bg-white/[0.04] dark:text-zinc-100 dark:shadow-none dark:hover:border-white/25 ${padding} ${className}`}
style={{ width, ...style }}
{...props}
>
{/* Base face (neutral ink). */}
<span aria-hidden="true" className="relative flex w-full">
{face}
</span>
{variant === "fill" ? (
<>
{/* Filled copy of the same face, revealed left → right by clip-path. */}
<motion.span
aria-hidden="true"
className={`absolute inset-0 flex items-center bg-[var(--pb-fill)] text-[var(--pb-ink)] transition-[background-color] duration-300 ${padding}`}
style={{ clipPath }}
>
{face}
<span
className={`pointer-events-none absolute inset-0 bg-[repeating-linear-gradient(-45deg,transparent_0_5px,rgba(0,0,0,0.09)_5px_10px)] transition-opacity duration-300 ${
status === "paused" ? "opacity-100" : "opacity-0"
}`}
/>
</motion.span>
{/* Leading edge of the sweep. */}
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 w-px -translate-x-px bg-white/70 mix-blend-overlay"
style={{ left: edgeLeft, opacity: edgeOpacity }}
/>
</>
) : (
<span aria-hidden="true" className="pointer-events-none absolute inset-x-4 bottom-[7px] h-[2px] overflow-hidden rounded-full bg-zinc-900/[0.07] dark:bg-white/10">
<motion.span className="absolute inset-0 origin-left rounded-full bg-[var(--pb-fill)]" style={{ scaleX: fill }} />
</span>
)}
</button>
</motion.span>
<AnimatePresence initial={false}>
{showCancel && (
<motion.span
key="cancel"
className="inline-flex shrink-0 overflow-hidden"
initial={reduceMotion ? { opacity: 0 } : { width: 0, opacity: 0 }}
animate={reduceMotion ? { opacity: 1 } : { width: 50, opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : { width: 0, opacity: 0 }}
transition={{ duration: reduceMotion ? 0.12 : 0.3, ease: EASE }}
>
<button
type="button"
aria-label={`Cancel ${label}`}
onClick={cancel}
className="ml-1.5 inline-flex size-11 shrink-0 items-center justify-center rounded-full border border-zinc-900/12 bg-white text-zinc-600 outline-hidden transition-colors hover:border-zinc-900/25 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-solid focus-visible:outline-[var(--pb-accent)] dark:border-white/12 dark:bg-white/[0.04] dark:text-zinc-400 dark:hover:border-white/25 dark:hover:text-zinc-100"
>
<Glyph kind="close" />
</button>
</motion.span>
)}
</AnimatePresence>
{/* Machine-readable progress for assistive tech, plus the description the button points at. */}
{status !== "idle" && (
<span
id={progressId}
role="progressbar"
aria-label={`${label} progress`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
aria-valuetext={description ?? `${pct}%`}
className="sr-only"
/>
)}
<span id={descriptionId} className="sr-only">
{description}
</span>
<span role="status" aria-live="polite" className="sr-only">
{announcement}
</span>
</span>
);
});