All patterns

Download Arrow Progress

The download glyph floods with color from the bottom as the transfer advances.

loadingminimalsubtleautomatic · finite · intermediate · ~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.

319 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react";

/**
 * Vibary · Download Arrow Progress
 *
 * The download glyph is the indicator: it floods with color from the
 * bottom as the transfer advances, then swaps for a tick. No second
 * widget, no bar competing with the icon for the same message.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the control reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `value`, `size`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type DownloadArrowProgressProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Target percentage, 0–100. Drive it from your transfer's progress event. */
  value?: number;
  /** Glyph tile size in px. */
  size?: number;
  /** Fill color. A literal accent: it is a state color, not a surface. */
  color?: string;
  /** Label while the transfer runs. */
  label?: string;
  /** Label once it lands. */
  doneLabel?: string;
  /** Caption under the labels. */
  caption?: string;
  /** Fires once the glyph is full. */
  onComplete?: () => void;
};

type VariantConfig = {
  fillSeconds: number;
  fillEase: "easeOut" | "easeInOut";
  settlePop: number;
  swapSpring: { type: "spring"; stiffness: number; damping: number };
  drawSeconds: number;
};

// Quality rule: the glyph is the only thing that moves. Labels cross-fade
// at a constant size — a download control lives in a toolbar and gets
// watched dozens of times a day, so anything springy wears out fast.
// Springs sit above critical damping; the settle is a single pulse.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Flood only, no settle. For a toolbar icon that should stay quiet.
  subtle: {
    fillSeconds: 1.1,
    fillEase: "easeOut",
    settlePop: 1,
    swapSpring: { type: "spring", stiffness: 560, damping: 44 },
    drawSeconds: 0.18,
  },
  // Eased flood and a barely-there settle. The all-purpose setting.
  default: {
    fillSeconds: 1.5,
    fillEase: "easeInOut",
    settlePop: 1.04,
    swapSpring: { type: "spring", stiffness: 460, damping: 40 },
    drawSeconds: 0.24,
  },
  // A longer flood and a visible settle — for a single export button
  // that owns the moment.
  playful: {
    fillSeconds: 1.9,
    fillEase: "easeInOut",
    settlePop: 1.08,
    swapSpring: { type: "spring", stiffness: 400, damping: 36 },
    drawSeconds: 0.3,
  },
};

const ACCENT = "#7C7CF0";
const SUCCESS = "#34D399";
const CHECK_PATH = "M6 12.4 10.3 16.7 18.4 8.1";

/** Theme-adaptive neutral: `currentColor` is the inherited text color, so
 *  mixing it with `transparent` yields a tile and a border correctly toned
 *  on light and dark pages. The accent and the success green stay literal —
 *  they are state colors, not surfaces. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** One glyph, drawn twice: once as the empty outline and once as the
 *  flooded copy the clip reveals. Keeping it in a single function is what
 *  guarantees the two layers register exactly. */
function ArrowGlyph({ stroke }: { stroke: string }) {
  return (
    <svg width="100%" height="100%" viewBox="0 0 24 24" fill="none">
      <path
        d="M12 3.4v11.3"
        stroke={stroke}
        strokeWidth="1.9"
        strokeLinecap="round"
      />
      <path
        d="M7.1 10.2 12 15.1l4.9-4.9"
        stroke={stroke}
        strokeWidth="1.9"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <path
        d="M4.2 17.1v1.3a2.2 2.2 0 0 0 2.2 2.2h11.2a2.2 2.2 0 0 0 2.2-2.2v-1.3"
        stroke={stroke}
        strokeWidth="1.9"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export default function DownloadArrowProgress({
  variant = "default",
  value = 100,
  size = 58,
  color = ACCENT,
  label = "Downloading",
  doneLabel = "Saved",
  caption = "annual-report.pdf · 8.6 MB",
  onComplete,
}: DownloadArrowProgressProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const target = Math.min(100, Math.max(0, value));
  // What the glyph has actually finished flooding to, rather than a done
  // flag: when the caller raises `value`, this stops matching and the
  // control drops back out of its finished state with no reset to write.
  const [filledTo, setFilledTo] = useState<number | null>(null);

  const progress = useMotionValue(0);
  // A clip-path rather than a height: the flooded copy keeps its own
  // geometry, so the arrow is revealed instead of being squashed, and
  // nothing in the layout is touched frame to frame.
  const clipPath = useTransform(
    progress,
    (current) => `inset(${(100 - current).toFixed(2)}% 0% 0% 0%)`
  );
  const percent = useTransform(progress, (current) => `${Math.round(current)}%`);

  const onCompleteRef = useRef(onComplete);
  useEffect(() => {
    onCompleteRef.current = onComplete;
  }, [onComplete]);

  useEffect(() => {
    // Reduced motion takes the same path at zero duration: the glyph lands
    // on the answer on the first frame and still reports through the same
    // callback. The filled shape and the number carry the state; the flood
    // 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 fade = { duration: 0.2, ease: "easeOut" as const };
  const glyph = Math.round(size * 0.54);

  return (
    <div
      role="progressbar"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={100}
      // The target, not the animated frame: assistive tech should hear
      // where the transfer stands, not follow the flood.
      aria-valuenow={target}
      style={{ display: "flex", alignItems: "center", gap: 13 }}
    >
      <motion.span
        aria-hidden
        initial={false}
        animate={{
          // One soft pulse on arrival, never a bounce, and only where the
          // variant asks for it.
          scale: complete && !reduceMotion ? [1, cfg.settlePop, 1] : 1,
        }}
        transition={{ duration: 0.36, ease: "easeOut" }}
        style={{
          position: "relative",
          flexShrink: 0,
          width: size,
          height: size,
          borderRadius: size * 0.3,
          background: tone(6),
          border: `1px solid ${tone(12)}`,
          display: "grid",
          placeItems: "center",
        }}
      >
        {/* The tile takes on a success wash instead of switching color, so
            the change is a layer fading in rather than a hue interpolation
            through mud. */}
        <motion.span
          initial={false}
          animate={{ opacity: complete ? 1 : 0 }}
          transition={fade}
          style={{
            position: "absolute",
            inset: 0,
            borderRadius: size * 0.3,
            background: `color-mix(in srgb, ${SUCCESS} 16%, transparent)`,
          }}
        />

        <span
          style={{ position: "relative", width: glyph, height: glyph }}
        >
          <motion.span
            initial={false}
            animate={{ opacity: complete ? 0 : 1 }}
            transition={fade}
            style={{ position: "absolute", inset: 0 }}
          >
            {/* Empty outline underneath… */}
            <span style={{ position: "absolute", inset: 0 }}>
              <ArrowGlyph stroke={tone(26)} />
            </span>
            {/* …and the same glyph in accent on top, revealed bottom-up. */}
            <motion.span
              style={{ position: "absolute", inset: 0, clipPath }}
            >
              <ArrowGlyph stroke={color} />
            </motion.span>
          </motion.span>

          <motion.span
            initial={false}
            animate={{
              opacity: complete ? 1 : 0,
              scale: complete || reduceMotion ? 1 : 0.72,
            }}
            transition={{ ...cfg.swapSpring, opacity: fade }}
            style={{ position: "absolute", inset: 0 }}
          >
            <svg width="100%" height="100%" viewBox="0 0 24 24" fill="none">
              {/* Drawn rather than faded: a stroke arriving in one motion
                  reads as "it just landed". */}
              <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.06 }
                }
              />
            </svg>
          </motion.span>
        </span>
      </motion.span>

      <div style={{ minWidth: 0 }}>
        {/* Both labels sit in one grid cell, so the block reserves the
            wider of the two and the caption never shifts under them. */}
        <span
          aria-hidden
          style={{ display: "grid", justifyItems: "start", whiteSpace: "nowrap" }}
        >
          <motion.span
            initial={false}
            animate={{ opacity: complete ? 0 : 1 }}
            transition={fade}
            style={{
              gridArea: "1 / 1",
              fontSize: 13.5,
              fontWeight: 600,
              lineHeight: 1.35,
              fontVariantNumeric: "tabular-nums",
              fontFeatureSettings: '"tnum"',
            }}
          >
            {label} <motion.span style={{ opacity: 0.55 }}>{percent}</motion.span>
          </motion.span>
          <motion.span
            initial={false}
            animate={{ opacity: complete ? 1 : 0 }}
            transition={fade}
            style={{
              gridArea: "1 / 1",
              fontSize: 13.5,
              fontWeight: 600,
              lineHeight: 1.35,
              color: SUCCESS,
            }}
          >
            {doneLabel}
          </motion.span>
        </span>
        <div style={{ fontSize: 11.5, opacity: 0.55, marginTop: 3 }}>
          {caption}
        </div>
      </div>
    </div>
  );
}

About this pattern

Instead of putting a meter next to the icon, the icon becomes the meter. The glyph is drawn twice — an empty outline and an accent copy stacked exactly on top — and the accent copy is revealed bottom-up by an animated clip-path, so the arrow floods rather than being squashed by a growing box. Nothing in the layout is touched frame to frame. On arrival the tile takes a success wash and the arrow swaps for a tick that draws itself, with at most a single soft pulse; the labels only ever cross-fade, because a control that lives in a toolbar gets watched dozens of times a day and anything springier wears out by the third look.

Download buttonExport a statementOffline syncReport generation

Where it shows up

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

  • 4:12 / 9:48
    Media player

    The offline-download control on an album fills before settling into a saved state.

Related patterns