All patterns

Upload Complete Fold

A finished upload's progress track folds away as the tile turns over to its thumbnail.

feedbackfriendlypremiumautomatic · finite · intermediate · ~2.0s
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.

331 lines · react + motion only
import { useEffect, useState, type CSSProperties } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Upload Complete Fold
 *
 * The moment an upload stops being a task and becomes a file: the
 * progress track folds away as the placeholder tile turns over to
 * reveal the thumbnail, leaving a settled file row behind.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Neutrals 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`, `fileName`, `uploadMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type UploadCompleteFoldProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  fileName?: string;
  /** Shown once the upload lands. */
  fileSize?: string;
  /** How long the track takes to fill, in ms. Drive it from real progress. */
  uploadMs?: number;
  /** CSS background for the revealed thumbnail — swap in your own image. */
  thumbnail?: string;
  /** Fires when the row has finished settling. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** How long the progress track takes to fold away. */
  foldSeconds: number;
  /** The tile turning over. */
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Beat before the badge lands on the tile. */
  badgeDelay: number;
};

// Damping ratios (damping / 2√stiffness) stay at or above 0.8: a tile
// that wobbles after turning over reads as a toy. Variants change how
// heavy the turn feels, never how many times it swings.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Quick fold, dead-flat turn. For a file list that fills up fast.
  subtle: {
    foldSeconds: 0.13,
    spring: { type: "spring", stiffness: 370, damping: 43 },
    badgeDelay: 0.04,
  },
  // One soft settle at the end of the turn. The all-purpose setting.
  default: {
    foldSeconds: 0.24,
    spring: { type: "spring", stiffness: 260, damping: 30 },
    badgeDelay: 0.08,
  },
  // Heavier, slower turn for a single hero upload.
  playful: {
    foldSeconds: 0.31,
    spring: { type: "spring", stiffness: 190, damping: 23 },
    badgeDelay: 0.12,
  },
};

const ACCENT = "#7C7CF0";
const DONE_COLOR = "#2FA36B";
/** Stands in for the uploaded image itself, so it stays literal. */
const THUMBNAIL =
  "linear-gradient(135deg, #6E7BF2 0%, #46A6D8 52%, #F0B863 100%)";

/** Theme-adaptive neutral: mixing the text color in scope with
 *  `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const FACE: CSSProperties = {
  position: "absolute",
  inset: 0,
  borderRadius: 10,
  display: "grid",
  placeItems: "center",
  overflow: "hidden",
};

export default function UploadCompleteFold({
  variant = "default",
  fileName = "campaign-hero.png",
  fileSize = "2.4 MB",
  uploadMs = 1600,
  thumbnail = THUMBNAIL,
  onComplete,
}: UploadCompleteFoldProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [done, setDone] = useState(false);
  // Reduced motion keeps the flip out of it; everything else still runs.
  const flip = !reduceMotion;

  useEffect(() => {
    const timer = setTimeout(() => setDone(true), uploadMs);
    return () => clearTimeout(timer);
  }, [uploadMs]);

  const glyph = (
    <svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden>
      <rect
        x="2.6"
        y="3.6"
        width="14.8"
        height="12.8"
        rx="2.4"
        stroke="currentColor"
        strokeWidth="1.4"
        opacity="0.55"
      />
      <circle cx="7.3" cy="8.1" r="1.4" fill="currentColor" opacity="0.45" />
      <path
        d="M3.4 14.6 7.7 10.6l2.6 2.3 3.1-3.3 3.4 3.7"
        stroke="currentColor"
        strokeWidth="1.4"
        strokeLinecap="round"
        strokeLinejoin="round"
        opacity="0.55"
      />
    </svg>
  );

  return (
    <div
      role="status"
      aria-live="polite"
      style={{
        width: 300,
        padding: 12,
        borderRadius: 14,
        background: tone(5),
        border: `1px solid ${tone(12)}`,
      }}
    >
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <div
          style={{
            position: "relative",
            width: 44,
            height: 44,
            flexShrink: 0,
            perspective: 620,
          }}
        >
          {flip ? (
            // A real two-sided tile: the placeholder is the front, the
            // thumbnail is the back, and the upload finishing is the turn.
            <motion.div
              initial={false}
              animate={{ rotateY: done ? 180 : 0 }}
              transition={cfg.spring}
              style={{
                position: "relative",
                width: "100%",
                height: "100%",
                transformStyle: "preserve-3d",
              }}
            >
              <span
                style={{
                  ...FACE,
                  backfaceVisibility: "hidden",
                  background: tone(10),
                  border: `1px solid ${tone(10)}`,
                }}
              >
                {glyph}
              </span>
              <span
                style={{
                  ...FACE,
                  backfaceVisibility: "hidden",
                  transform: "rotateY(180deg)",
                  background: thumbnail,
                }}
              />
            </motion.div>
          ) : (
            // Reduced motion: no turn. The tile still becomes the
            // thumbnail, it just crossfades into it.
            <div style={{ position: "relative", width: "100%", height: "100%" }}>
              <span
                style={{
                  ...FACE,
                  background: tone(10),
                  border: `1px solid ${tone(10)}`,
                }}
              >
                {glyph}
              </span>
              <motion.span
                initial={false}
                animate={{ opacity: done ? 1 : 0 }}
                transition={{ duration: 0.2, ease: "easeOut" }}
                style={{ ...FACE, background: thumbnail }}
              />
            </div>
          )}

          <motion.span
            aria-hidden
            initial={false}
            animate={{
              opacity: done ? 1 : 0,
              scale: done || reduceMotion ? 1 : 0.5,
            }}
            transition={
              reduceMotion
                ? { duration: 0.18, ease: "easeOut" }
                : {
                    ...cfg.spring,
                    delay: done ? cfg.badgeDelay : 0,
                    opacity: { duration: 0.16, ease: "easeOut" },
                  }
            }
            style={{
              position: "absolute",
              right: -5,
              bottom: -5,
              width: 18,
              height: 18,
              borderRadius: "50%",
              background: DONE_COLOR,
              display: "grid",
              placeItems: "center",
              boxShadow: "0 2px 6px rgba(0,0,0,0.22)",
            }}
          >
            <svg width="10" height="10" viewBox="0 0 16 16" fill="none">
              <path
                d="M4 8.3 6.7 11 12 5.4"
                stroke="#FFFFFF"
                strokeWidth="2.1"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          </motion.span>
        </div>

        <div style={{ flex: 1, minWidth: 0 }}>
          <div
            style={{
              fontSize: 13,
              fontWeight: 600,
              whiteSpace: "nowrap",
              overflow: "hidden",
              textOverflow: "ellipsis",
            }}
          >
            {fileName}
          </div>
          {/* Both states occupy the same slot and crossfade in place, so
              the row's type never moves or changes size. */}
          <div style={{ position: "relative", height: 16, marginTop: 2 }}>
            {[
              { text: "Uploading", active: !done },
              { text: `Uploaded · ${fileSize}`, active: done },
            ].map((entry) => (
              <motion.span
                key={entry.text}
                aria-hidden={!entry.active}
                initial={false}
                animate={{ opacity: entry.active ? 0.55 : 0 }}
                transition={{ duration: 0.2, ease: "easeOut" }}
                style={{
                  position: "absolute",
                  inset: 0,
                  fontSize: 11.5,
                  lineHeight: "16px",
                  whiteSpace: "nowrap",
                }}
              >
                {entry.text}
              </motion.span>
            ))}
          </div>
        </div>
      </div>

      {/* The fold: one height tween on a wrapper that has nothing to do
          but disappear. It is a genuine size change, so it is short and
          eased rather than sprung. */}
      <motion.div
        initial={false}
        animate={{ height: done ? 0 : 16, opacity: done ? 0 : 1 }}
        transition={{
          duration: reduceMotion ? 0.16 : cfg.foldSeconds,
          ease: [0.4, 0, 0.2, 1],
        }}
        onAnimationComplete={() => {
          if (done) onComplete?.();
        }}
        style={{ overflow: "hidden" }}
      >
        <div
          style={{
            marginTop: 10,
            height: 6,
            borderRadius: 999,
            background: tone(12),
            overflow: "hidden",
          }}
        >
          {/* scaleX rather than width: the track fills without asking the
              row to relayout on every frame. Linear, because a progress
              line that eases is lying about the clock. */}
          <motion.div
            initial={{ scaleX: 0 }}
            animate={{ scaleX: 1 }}
            transition={{
              duration: reduceMotion ? 0 : uploadMs / 1000,
              ease: "linear",
            }}
            style={{
              height: "100%",
              borderRadius: 999,
              transformOrigin: "left center",
              background: ACCENT,
            }}
          />
        </div>
      </motion.div>
    </div>
  );
}

About this pattern

The moment an upload stops being a task and becomes a file. The track fills on a linear curve, then folds out of the row in one short height tween while the placeholder tile turns over on its vertical axis to reveal the thumbnail behind it, and a small confirmation badge lands a beat later. The filename never moves and the status line crossfades in a fixed slot, so the row shrinks around the type rather than dragging it. Reduced motion keeps the reveal and drops the turn.

File uploaderAttachment in a composerMedia library importDocument intake queue

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

    Upload tray row drops its progress track and shows the file thumbnail once the transfer lands.

Related patterns