File Upload Progress
A file row's bar fills as bytes land while the percentage and the counter roll with it.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef, useState } from "react";
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
/**
* Vibary · File Upload Progress
*
* A file row whose bar fills as bytes land. The percentage, the byte
* counter and the bar are all read off one motion value, so they can
* never disagree — then the readout gives way to a drawn tick.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the row reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `value`, `fileName`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type FileUploadProgressProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Target percentage, 0–100. Drive it from your upload's progress event. */
value?: number;
/** Name shown on the row. */
fileName?: string;
/** Total size in bytes — the byte counter is derived from it. */
fileBytes?: number;
/** Filled bar color. A literal accent: it is a state color, not a surface. */
color?: string;
/** Row width — px number or any CSS length. */
width?: number | string;
/** Fires once the bar reaches its target. */
onComplete?: () => void;
};
type VariantConfig = {
fillSeconds: number;
fillEase: "easeOut" | "easeInOut";
barHeight: number;
swapSpring: { type: "spring"; stiffness: number; damping: number };
drawSeconds: number;
};
// Quality rule: only the tick and the bar move. The filename, the
// percentage and the byte counter are text and never scale — a row like
// this appears five at a time in an upload tray, and five bouncing
// numbers is a slot machine. Springs sit above critical damping.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Quick, front-loaded fill and a thin bar. For a tray of several
// uploads where no single row should pull the eye.
subtle: {
fillSeconds: 1.1,
fillEase: "easeOut",
barHeight: 4,
swapSpring: { type: "spring", stiffness: 560, damping: 44 },
drawSeconds: 0.18,
},
// Eased fill, readable counter. The all-purpose setting.
default: {
fillSeconds: 1.5,
fillEase: "easeInOut",
barHeight: 6,
swapSpring: { type: "spring", stiffness: 460, damping: 38 },
drawSeconds: 0.24,
},
// A longer climb for a single hero upload where watching the bytes go
// up is the whole screen.
playful: {
fillSeconds: 1.9,
fillEase: "easeInOut",
barHeight: 8,
swapSpring: { type: "spring", stiffness: 380, damping: 34 },
drawSeconds: 0.3,
},
};
const ACCENT = "#7C7CF0";
const SUCCESS = "#34D399";
const CHECK_PATH = "M3.6 8.5 6.7 11.6 12.6 5.3";
/** Theme-adaptive neutral: `currentColor` is the inherited text color —
* near-black on a light page, near-white on a dark one — so mixing it
* with `transparent` yields surfaces and borders that are correctly
* toned in either theme without knowing the page background. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
function formatMb(bytes: number) {
return `${(bytes / 1_000_000).toFixed(1)} MB`;
}
export default function FileUploadProgress({
variant = "default",
value = 100,
fileName = "q3-forecast.xlsx",
fileBytes = 2_412_000,
color = ACCENT,
width = 320,
onComplete,
}: FileUploadProgressProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const target = Math.min(100, Math.max(0, value));
// What the bar has actually finished filling to, rather than a done
// flag: when the caller raises `value`, this stops matching and the row
// drops back out of its finished state by itself, with no reset to write.
const [filledTo, setFilledTo] = useState<number | null>(null);
// One source of truth. The bar is a scaleX off this value rather than a
// width, so the fill is composited instead of relayed out on every frame.
const progress = useMotionValue(0);
const scaleX = useTransform(progress, [0, 100], [0, 1]);
const percent = useTransform(progress, (current) => `${Math.round(current)}%`);
const sent = useTransform(progress, (current) =>
formatMb((fileBytes * current) / 100)
);
// The callback lives in a ref so an inline arrow from the parent can't
// re-trigger the effect and restart the fill halfway through.
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
useEffect(() => {
// Reduced motion takes the same path at zero duration: the bar lands on
// the answer on the first frame and still reports through the same
// callback. The number and the bar are the information — the climb was
// only ever the delivery.
const controls = animate(progress, target, {
duration: reduceMotion ? 0 : cfg.fillSeconds,
ease: cfg.fillEase,
onComplete: () => {
setFilledTo(target);
onCompleteRef.current?.();
},
});
return () => controls.stop();
}, [target, reduceMotion, cfg, progress]);
const complete = filledTo === target && target >= 100;
const swap = reduceMotion
? { duration: 0.16, ease: "easeOut" as const }
: cfg.swapSpring;
const fade = { duration: 0.18, ease: "easeOut" as const };
return (
<div
role="progressbar"
aria-label={`Uploading ${fileName}`}
aria-valuemin={0}
aria-valuemax={100}
// The target, not the animated frame: assistive tech should hear
// where the transfer actually stands, not follow the count.
aria-valuenow={target}
style={{
width,
display: "flex",
alignItems: "center",
gap: 12,
padding: 12,
borderRadius: 12,
background: tone(5),
border: `1px solid ${tone(11)}`,
boxSizing: "border-box",
}}
>
<span
aria-hidden
style={{
flexShrink: 0,
display: "grid",
placeItems: "center",
width: 34,
height: 34,
borderRadius: 9,
background: tone(8),
color: "inherit",
}}
>
<svg width="17" height="17" viewBox="0 0 20 20" fill="none">
<path
d="M11.5 2.5H6a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7l-4.5-4.5Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
opacity="0.75"
/>
<path
d="M11.4 2.6V7h4.4"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
opacity="0.75"
/>
</svg>
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 10,
}}
>
<span
style={{
fontSize: 13,
fontWeight: 560,
lineHeight: 1.3,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{fileName}
</span>
{/* Readout and tick share one grid cell, so the row reserves the
wider of the two up front and the swap can't nudge the name. */}
<span
aria-hidden
style={{
flexShrink: 0,
display: "grid",
justifyItems: "end",
minWidth: 42,
}}
>
<motion.span
initial={false}
animate={{ opacity: complete ? 0 : 1 }}
transition={fade}
style={{
gridArea: "1 / 1",
fontSize: 12.5,
fontWeight: 600,
lineHeight: 1.3,
// Tabular figures stop the percentage twitching sideways
// as it climbs through 9 → 10 → 100.
fontVariantNumeric: "tabular-nums",
fontFeatureSettings: '"tnum"',
}}
>
{percent}
</motion.span>
<motion.span
initial={false}
animate={{
opacity: complete ? 1 : 0,
scale: complete ? 1 : reduceMotion ? 1 : 0.7,
}}
transition={{ ...swap, opacity: fade }}
style={{
gridArea: "1 / 1",
display: "grid",
placeItems: "center",
width: 17,
height: 17,
borderRadius: "50%",
background: `color-mix(in srgb, ${SUCCESS} 22%, transparent)`,
}}
>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none">
{/* Drawn rather than faded: a stroke arriving in one motion
reads as "it just finished", where a fade reads as
"it was already done". */}
<motion.path
d={CHECK_PATH}
stroke={SUCCESS}
strokeWidth="2.1"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: complete ? 1 : 0 }}
transition={
reduceMotion
? { duration: 0 }
: { duration: cfg.drawSeconds, ease: "easeOut", delay: 0.04 }
}
/>
</svg>
</motion.span>
</span>
</div>
<div
aria-hidden
style={{
marginTop: 8,
height: cfg.barHeight,
borderRadius: cfg.barHeight,
background: tone(11),
overflow: "hidden",
}}
>
<motion.div
style={{
scaleX,
// Without this the bar would grow from its middle outwards,
// which reads as a meter rather than a transfer.
transformOrigin: "left center",
height: "100%",
borderRadius: cfg.barHeight,
background: `linear-gradient(90deg, ${color}, color-mix(in srgb, ${color} 68%, white))`,
}}
/>
</div>
<div
aria-hidden
style={{
marginTop: 7,
fontSize: 11.5,
opacity: 0.55,
lineHeight: 1.3,
display: "flex",
gap: 5,
fontVariantNumeric: "tabular-nums",
fontFeatureSettings: '"tnum"',
}}
>
<motion.span>{sent}</motion.span>
<span>of {formatMb(fileBytes)}</span>
</div>
</div>
</div>
);
}About this pattern
The honest version of an upload row. A bar fills from the left while the percentage and the megabyte counter climb beside it, and all three are read off a single motion value, so the number can never disagree with the bar it sits above. The fill is a scaleX rather than a width, which keeps it on the compositor instead of relaying out the row on every frame. When the transfer lands, the readout gives way to a tick that draws itself — a stroke arriving in one motion reads as 'it just finished', where a fade reads as 'it was already done'. The filename and both numbers are text, so they cross-fade and never scale.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- File browser
Each file's row carries its own bar, and the row settles once the bar completes.
Related patterns
- Determinate Progress RingA ring fills from empty to full while the percentage counts up inside it.
- Download Arrow ProgressThe download glyph floods with color from the bottom as the transfer advances.
- Spinner to CheckThe waiting arc finishes its turn, closes into a full ring, and the confirming stroke lands inside it.