Upload Complete Fold
A finished upload's progress track folds away as the tile turns over to its thumbnail.
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, 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- File browser
Upload tray row drops its progress track and shows the file thumbnail once the transfer lands.
Related patterns
- Permission Request RiseA permission card rises with its icon settling a beat before the explanation.
- Connection RestoredThe offline bar turns green, confirms, and retracts in one continuous move.
- Credit Deduct TickA usage balance ticks down to its new figure while the amount taken rises beside it and leaves.