All patterns

File Upload Progress

A file row's bar fills as bytes land while the percentage and the counter roll with it.

loadingminimalfriendlyautomatic · finite · starter · ~1.5s
Variant

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.

337 lines · react + motion only
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.

Attachment trayDocument uploadMedia importBulk file transfer

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Files
    Recent
    Shared
    Starred
    Trash
    FilesNew
    Brand refresh.figPriya Raman · 2.4 MB
    Q3 planning.pdfMarcus Bell · 840 KB
    Supplier contract.docxDana Whitfield · 96 KB
    Photography brief.mdNils Bergström · 12 KB
    Invoice 4821.pdfBilling · 64 KB
    File browser

    Each file's row carries its own bar, and the row settles once the bar completes.

Related patterns