File upload where progress is the container: a loose dashed outline 24px outside the card pulls taut as the file uploads, clicks shut with a pucker at 100%, and relaxes back out with a wobble on failure.
npx shadcn add https://design.helpmarq.com /r/file-upload-seal.jsonregistry/core/file-upload-seal/component.tsx"use client";
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type DragEvent,
type KeyboardEvent,
type RefObject,
} from "react";
// ---------------------------------------------------------------------------
// VacuumSeal — a file upload zone where progress IS the container, not a
// bar appended to it. Dropping a file spawns a loose dashed SVG rounded-rect
// outline 24px outside the card; as upload progress advances, one
// critically-damped spring per file pulls that outline inward — inset,
// dasharray, and a muted->foreground stroke crossfade all riding the same
// spring parameter — so it visibly breathes toward the card rather than
// stepping. Arrival at 100% is a real end-state: status flips to "sealed"
// and the card plays a one-shot scale(0.99) pucker-and-release. A failed
// upload targets the spring back to its loose, 24px-out rest position with
// light underdamping, so it visibly overshoots and wobbles before settling
// — the same spring engine, just less damped, drawn in --error instead of
// muted/foreground. Direct-DOM per-card rAF loops, one per uploading/sealing
// file, sleep once each has settled; pre-seeded (defaultFiles) entries never
// run the spring at all — their geometry is written once, synchronously,
// before paint.
// ---------------------------------------------------------------------------
const OUTER_GAP = 24; // px — loose outline distance at progress 0
const WOBBLE = 6; // px — extra overshoot room for the fail-relax bounce
const PAD = OUTER_GAP + WOBBLE + 4; // svg overhang each side
const CARD_RADIUS = 12; // matches the card's own rounded-md
const SEAL_K = 90; // spring constant while sealing/uploading (critically damped)
const FAIL_K = 46; // spring constant while relaxing after a failure (underdamped)
const FAIL_ZETA = 0.4;
const PUCKER_MS = 260;
type UploadStatus = "uploading" | "sealed" | "failed";
export interface VacuumSealFile {
id: string;
name: string;
size: number;
type: string;
}
interface FileEntry extends VacuumSealFile {
status: UploadStatus;
progress: number; // 0..1
}
function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
return `${bytes} B`;
}
function acceptSummary(accept: string[]): string {
if (accept.length === 0) return "Any file type";
return accept
.map((a) => (a.startsWith(".") ? a.slice(1).toUpperCase() : a))
.join(", ");
}
function statusText(f: FileEntry): string {
const size = formatSize(f.size);
if (f.status === "sealed") return `${size} · sealed`;
if (f.status === "failed") return `upload failed · ${size}`;
return `uploading ${size}… ${Math.round(f.progress * 100)}%`;
}
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v));
}
// ---------------------------------------------------------------------------
// SealOutline — the per-card spring engine. Drives an SVG rect (or two, for
// the muted/foreground crossfade) via direct attribute writes, never React
// state. `dp` is a generalized "seal progress" spring variable: 0 = loose
// outline 24px out, 1 = flush/taut. For status "failed" the spring target is
// 0 with light underdamping, so dp is allowed to swing slightly negative —
// that alone produces the relax-with-wobble, no separate keyframe needed.
// ---------------------------------------------------------------------------
function useSealOutline(
wrapRef: RefObject<HTMLDivElement | null>,
svgRef: RefObject<SVGSVGElement | null>,
mutedRef: RefObject<SVGRectElement | null>,
fgRef: RefObject<SVGRectElement | null>,
errRef: RefObject<SVGRectElement | null>,
status: UploadStatus,
progress: number,
reduced: boolean,
frozen: boolean // pre-seeded entries: write geometry once, never spring
) {
const dpRef = useRef(status === "sealed" ? 1 : status === "failed" ? 0 : progress);
const vRef = useRef(0);
const wRef = useRef(0);
const hRef = useRef(0);
const rafRef = useRef(0);
const lastRef = useRef(0);
const applyGeom = useCallback(() => {
const w = wRef.current;
const h = hRef.current;
if (w < 1 || h < 1) return;
const dp = dpRef.current;
const offset = clamp(OUTER_GAP * (1 - dp), 0, OUTER_GAP + WOBBLE);
const rx = CARD_RADIUS + offset * 0.55;
const x = PAD - offset;
const y = PAD - offset;
const rw = Math.max(0, w + offset * 2);
const rh = Math.max(0, h + offset * 2);
const svg = svgRef.current;
if (svg) {
svg.setAttribute("width", String(w + PAD * 2));
svg.setAttribute("height", String(h + PAD * 2));
}
const pC = clamp(dp, 0, 1);
const dashLen = (8 - 7 * pC).toFixed(2);
const gapLen = (4 - 4 * pC).toFixed(2);
for (const r of [mutedRef.current, fgRef.current, errRef.current]) {
if (!r) continue;
r.setAttribute("x", x.toFixed(2));
r.setAttribute("y", y.toFixed(2));
r.setAttribute("width", rw.toFixed(2));
r.setAttribute("height", rh.toFixed(2));
r.setAttribute("rx", rx.toFixed(2));
}
if (mutedRef.current) {
mutedRef.current.setAttribute("stroke-dasharray", `${dashLen} ${gapLen}`);
mutedRef.current.style.opacity = String(1 - pC);
}
if (fgRef.current) {
fgRef.current.setAttribute("stroke-dasharray", `${dashLen} ${gapLen}`);
fgRef.current.style.opacity = String(pC);
}
// errRef keeps its fixed loose dasharray (set in markup) — only geometry moves
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// measure + write geometry synchronously before first paint — this is what
// keeps a pre-seeded (frozen) card static at rest instead of springing in
useLayoutEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
const measure = () => {
const rect = wrap.getBoundingClientRect();
wRef.current = rect.width;
hRef.current = rect.height;
applyGeom();
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(wrap);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (frozen) return; // pre-seeded: geometry already written once, above
if (reduced) {
// discrete quartile steps, no spring, no overshoot
const target = status === "failed" ? 0 : status === "sealed" ? 1 : progress;
dpRef.current = status === "failed" ? 0 : Math.floor(clamp(target, 0, 1) * 4) / 4;
vRef.current = 0;
applyGeom();
return;
}
const target = status === "failed" ? 0 : status === "sealed" ? 1 : progress;
const k = status === "failed" ? FAIL_K : SEAL_K;
const zeta = status === "failed" ? FAIL_ZETA : 1;
const c = 2 * zeta * Math.sqrt(k);
const loop = (now: number) => {
const dt = lastRef.current ? Math.min(0.05, (now - lastRef.current) / 1000) : 1 / 60;
lastRef.current = now;
const dp = dpRef.current;
const err = target - dp;
vRef.current += (k * err - c * vRef.current) * dt;
dpRef.current = dp + vRef.current * dt;
applyGeom();
if (Math.abs(target - dpRef.current) < 0.0025 && Math.abs(vRef.current) < 0.01) {
dpRef.current = target;
vRef.current = 0;
applyGeom();
rafRef.current = 0;
lastRef.current = 0;
return;
}
rafRef.current = requestAnimationFrame(loop);
};
lastRef.current = 0;
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafRef.current);
}, [status, progress, reduced, frozen, applyGeom]);
}
function FileCard({
file,
reduced,
frozen,
}: {
file: FileEntry;
reduced: boolean;
frozen: boolean;
}) {
const wrapRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const mutedRef = useRef<SVGRectElement>(null);
const fgRef = useRef<SVGRectElement>(null);
const errRef = useRef<SVGRectElement>(null);
const prevStatusRef = useRef(file.status);
const [justSealed, setJustSealed] = useState(false);
const progId = useId();
useSealOutline(
wrapRef,
svgRef,
mutedRef,
fgRef,
errRef,
file.status,
file.progress,
reduced,
frozen
);
useEffect(() => {
if (!frozen && !reduced && prevStatusRef.current !== "sealed" && file.status === "sealed") {
setJustSealed(true);
}
prevStatusRef.current = file.status;
}, [file.status, frozen, reduced]);
const pct = Math.round((file.status === "sealed" ? 1 : file.progress) * 100);
return (
<li className="relative" style={{ margin: `${PAD - 10}px 0` }}>
<div
ref={wrapRef}
data-just-sealed={justSealed || undefined}
onAnimationEnd={() => setJustSealed(false)}
className="ns-file-upload-seal-card relative rounded-md border border-border bg-surface"
>
<svg
ref={svgRef}
aria-hidden
className="pointer-events-none absolute overflow-visible"
style={{ left: -PAD, top: -PAD }}
>
{file.status !== "failed" ? (
<>
<rect
ref={mutedRef}
className="text-muted"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
/>
<rect
ref={fgRef}
className="text-foreground"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
/>
</>
) : (
<rect
ref={errRef}
fill="none"
strokeWidth={1.5}
strokeDasharray="8 4"
style={{ stroke: "var(--error, #ea001d)" }}
/>
)}
</svg>
<div className="relative z-[1] flex items-center gap-3 px-4 py-3">
<span className="shrink-0 text-muted" aria-hidden>
{file.status === "sealed" ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4 text-foreground">
<path d="M4 12.5 9.5 18 20 6" />
</svg>
) : file.status === "failed" ? (
<svg viewBox="0 0 24 24" fill="none" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4" style={{ stroke: "var(--error, #ea001d)" }}>
<path d="M12 9v4M12 16.5v.01" />
<path d="M10.3 3.9 2.6 17a1.8 1.8 0 0 0 1.55 2.7h15.7a1.8 1.8 0 0 0 1.55-2.7L13.7 3.9a1.8 1.8 0 0 0-3.4 0Z" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />
</svg>
)}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">{file.name}</p>
<p
id={progId}
role="progressbar"
aria-label={`${file.name} upload progress`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
aria-valuetext={statusText(file)}
className="font-mono text-[11px] text-muted"
>
{statusText(file)}
</p>
</div>
</div>
</div>
</li>
);
}
// ---------------------------------------------------------------------------
// simulated upload — used whenever a consumer does not pass `uploadFile`.
// Chunky, irregular progress events (not a smooth ramp) are the point: it's
// what gives the spring something to visibly catch up to.
// ---------------------------------------------------------------------------
function simulateUpload(
onProgress: (p: number) => void,
onDone: (ok: boolean) => void
): () => void {
let p = 0;
const failAt = Math.random() < 0.16 ? 0.25 + Math.random() * 0.55 : 2; // 2 = never
const id = setInterval(
() => {
p = Math.min(1, p + 0.1 + Math.random() * 0.16);
if (p >= failAt) {
clearInterval(id);
onProgress(Math.min(p, 0.97));
onDone(false);
return;
}
onProgress(p);
if (p >= 1) {
clearInterval(id);
onDone(true);
}
},
170 + Math.random() * 110
);
return () => clearInterval(id);
}
export function VacuumSeal({
defaultFiles = [],
accept = [],
maxSizeBytes = 25 * 1024 * 1024,
uploadFile,
onFilesChange,
className = "",
"aria-label": ariaLabel = "Upload files",
}: {
/** files rendered already in their final state — sealed, mid-upload, or failed — at mount, with no entrance animation */
defaultFiles?: {
name: string;
size: number;
type: string;
status?: UploadStatus;
progress?: number;
}[];
/** allowed types: extensions (".png") or mime ("image/png", "image/*"); empty = any */
accept?: string[];
maxSizeBytes?: number;
/** perform the real upload, reporting 0..1 via onProgress; reject to fail the seal. omit to use a built-in simulated upload. */
uploadFile?: (file: File, onProgress: (p: number) => void) => Promise<void>;
onFilesChange?: (files: VacuumSealFile[]) => void;
className?: string;
"aria-label"?: string;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const idSeq = useRef(0);
const dragDepth = useRef(0);
const cleanupsRef = useRef(new Map<string, () => void>());
const quartileRef = useRef(new Map<string, number>());
const frozenIdsRef = useRef(new Set<string>());
const [files, setFiles] = useState<FileEntry[]>(() =>
defaultFiles.map((f, i) => {
const id = `vs-d${i}`;
frozenIdsRef.current.add(id);
return {
id,
name: f.name,
size: f.size,
type: f.type,
status: f.status ?? "sealed",
progress: f.progress ?? (f.status === "failed" ? 0 : 1),
};
})
);
const [isOver, setIsOver] = useState(false);
const [reduced, setReduced] = useState(false);
const [announce, setAnnounce] = useState("");
const hintId = useId();
const lastNotifyKeyRef = useRef("");
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const onChange = () => setReduced(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
useEffect(() => {
const cleanups = cleanupsRef.current;
return () => {
for (const stop of cleanups.values()) stop();
cleanups.clear();
};
}, []);
const publicFiles = (list: FileEntry[]): VacuumSealFile[] =>
list.map(({ id, name, size, type }) => ({ id, name, size, type }));
// notify from an effect, keyed on the id set (add/remove only, not every
// progress tick) — never call a consumer's setState from inside a
// setFiles updater, which runs during this component's own render
useEffect(() => {
const pub = publicFiles(files);
const key = pub.map((f) => f.id).join(",");
if (key !== lastNotifyKeyRef.current) {
lastNotifyKeyRef.current = key;
onFilesChange?.(pub);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [files]);
const startUpload = (id: string, name: string, file: File) => {
const onProgress = (p: number) => {
setFiles((fs) => fs.map((f) => (f.id === id ? { ...f, progress: p } : f)));
const bucket = p >= 1 ? 4 : Math.floor(p * 4);
const prev = quartileRef.current.get(id) ?? 0;
if (bucket > prev && bucket > 0 && bucket < 4) {
quartileRef.current.set(id, bucket);
setAnnounce(`${name} ${bucket * 25}% uploaded`);
}
};
const finalize = (ok: boolean) => {
cleanupsRef.current.delete(id);
setFiles((fs) =>
fs.map((f) =>
f.id === id ? { ...f, status: ok ? "sealed" : "failed", progress: ok ? 1 : f.progress } : f
)
);
setAnnounce(ok ? `${name} sealed` : `${name} upload failed`);
};
if (uploadFile) {
let live = true;
uploadFile(file, (p) => live && onProgress(p)).then(
() => live && finalize(true),
() => live && finalize(false)
);
cleanupsRef.current.set(id, () => {
live = false;
});
} else {
const stop = simulateUpload(onProgress, finalize);
cleanupsRef.current.set(id, stop);
}
};
const validate = (file: File): string | null => {
if (file.size > maxSizeBytes) return `exceeds the ${formatSize(maxSizeBytes)} limit`;
if (accept.length > 0) {
const lower = file.name.toLowerCase();
const ok = accept.some((a) => {
const s = a.trim().toLowerCase();
if (s.startsWith(".")) return lower.endsWith(s);
if (s.endsWith("/*")) return file.type.startsWith(s.slice(0, -1));
return file.type === s;
});
if (!ok) return "file type not accepted";
}
return null;
};
const processFiles = (list: FileList | null) => {
if (!list || list.length === 0) return;
const accepted: FileEntry[] = [];
const messages: string[] = [];
for (const file of Array.from(list)) {
const reason = validate(file);
if (reason !== null) {
messages.push(`${file.name} rejected — ${reason}`);
continue;
}
const id = `vs-${idSeq.current++}`;
accepted.push({
id,
name: file.name,
size: file.size,
type: file.type,
status: "uploading",
progress: 0,
});
startUpload(id, file.name, file);
}
if (accepted.length > 0) {
setFiles((fs) => [...fs, ...accepted]);
messages.push(...accepted.map((f) => `${f.name} added`));
}
if (messages.length > 0) setAnnounce(messages.join(". "));
};
const openPicker = () => inputRef.current?.click();
const onZoneKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openPicker();
}
};
const dragHasFiles = (e: DragEvent<HTMLDivElement>) =>
Array.from(e.dataTransfer?.types ?? []).includes("Files");
const onDragEnter = (e: DragEvent<HTMLDivElement>) => {
if (!dragHasFiles(e)) return;
e.preventDefault();
dragDepth.current += 1;
if (dragDepth.current === 1) setIsOver(true);
};
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!dragHasFiles(e)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
};
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
if (!dragHasFiles(e)) return;
e.preventDefault();
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setIsOver(false);
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
if (!dragHasFiles(e)) return;
e.preventDefault();
dragDepth.current = 0;
setIsOver(false);
processFiles(e.dataTransfer?.files ?? null);
};
// -- landing-card autoplay bridge -----------------------------------------
// Mirrors file-upload-thermal's pattern: the shared pointer-path driver only
// dispatches Pointer/Mouse events, so a sweep across the zone is bridged
// into a synthetic drop on its "leave" (sweep-closing) edge, via the exact
// same processFiles() a real onDrop calls with a File built through the
// standard constructible DataTransfer. Every visual — outline appearing,
// spring-tightening, pucker, or fail-wobble — is the real engine above;
// nothing autoplay-specific is animated. Bounded: the previous demo file
// is cleared before the next is added, so a card left running never
// accumulates rows. Gated on [data-autoplay-root], stamped only for
// embed=1&autoplay=1, so a real hover on /preview/file-upload-seal never wires
// this up.
useEffect(() => {
const zone = zoneRef.current;
if (!zone || !zone.closest("[data-autoplay-root]")) return;
let dragging = false;
let demoId: string | null = null;
const onEnter = () => {
dragging = true;
setIsOver(true);
};
const onLeave = () => {
if (!dragging) return;
dragging = false;
setIsOver(false);
if (demoId) {
const closing = demoId;
cleanupsRef.current.get(closing)?.();
cleanupsRef.current.delete(closing);
setFiles((fs) => fs.filter((f) => f.id !== closing));
}
const dt = new DataTransfer();
dt.items.add(new File(["demo"], "quarter-report.pdf", { type: "application/pdf" }));
// idSeq is incremented exactly once per accepted file inside
// processFiles, synchronously and in order — capturing it just before
// the call gives the exact id the single dropped file will receive,
// with no need to diff file lists afterward.
const nextId = `vs-${idSeq.current}`;
processFiles(dt.files);
demoId = nextId;
};
zone.addEventListener("pointerenter", onEnter);
zone.addEventListener("pointerleave", onLeave);
return () => {
zone.removeEventListener("pointerenter", onEnter);
zone.removeEventListener("pointerleave", onLeave);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hint = `${acceptSummary(accept)} · max ${formatSize(maxSizeBytes)}`;
return (
<div ref={rootRef} className={`flex flex-col gap-2 ${className}`}>
<div
ref={zoneRef}
role="button"
tabIndex={0}
aria-label={ariaLabel}
aria-describedby={hintId}
onClick={openPicker}
onKeyDown={onZoneKey}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={`relative flex min-h-[7rem] cursor-pointer flex-col items-center justify-center gap-1.5 rounded-md border border-dashed px-6 py-7 text-center outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
isOver
? "border-accent bg-accent/[0.05]"
: "border-border bg-surface hover:border-foreground/25"
}`}
>
<svg
aria-hidden
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={`h-5 w-5 transition-colors duration-200 ${isOver ? "text-accent" : "text-muted"}`}
>
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="M9 12h6M12 9v6" />
</svg>
<p className="text-sm text-foreground">
Drop files to seal, or{" "}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openPicker();
}}
className="rounded-[4px] font-medium text-accent underline-offset-2 outline-none transition-colors hover:underline focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
browse
</button>
</p>
<p id={hintId} className="font-mono text-[11px] tracking-wide text-muted">
{hint}
</p>
</div>
<input
ref={inputRef}
type="file"
multiple
accept={accept.length > 0 ? accept.join(",") : undefined}
tabIndex={-1}
aria-hidden="true"
className="sr-only"
onChange={(e) => {
processFiles(e.target.files);
e.target.value = "";
}}
/>
{files.length > 0 && (
<ul role="list" aria-label="Uploads" className="flex flex-col">
{files.map((f) => (
<FileCard key={f.id} file={f} reduced={reduced} frozen={frozenIdsRef.current.has(f.id)} />
))}
</ul>
)}
<p role="status" aria-live="polite" className="min-h-4 font-mono text-[11px] text-muted">
{announce}
</p>
<style>{`
.ns-file-upload-seal-card[data-just-sealed] {
animation: ns-file-upload-seal-pucker ${PUCKER_MS}ms cubic-bezier(0.32, 1.6, 0.5, 1) both;
}
@keyframes ns-file-upload-seal-pucker {
0% { transform: scale(1); }
40% { transform: scale(0.99); }
100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.ns-file-upload-seal-card[data-just-sealed] { animation: none; }
}
`}</style>
</div>
);
}
a file's own edges should read as the progress indicator — the gap between a dashed outline and the card IS the remaining upload, tightening to a taut seal at 100% with a physical end-state instead of a bar disappearing at full. Pick file-upload-thermal instead when the moment that matters is the drop itself (drag-in lift, accept/reject flight) rather than what happens to a file after it lands.
Build a file upload zone (focusable role=button zone plus hidden <input type=file multiple>, real Enter/Space/click parity, HTML5 drag-and-drop as a bonus path) where each accepted file renders as a card with its own SVG rounded-rect outline drawn 24px outside the card's silhouette. One per-file critically-damped spring (k≈90, zeta=1) drives a single generalized 'seal progress' value dp (0 = loose outline 24px out, 1 = flush/taut) toward whatever the file's real upload progress currently is; every frame, direct SVG attribute writes (never React state) recompute the rect's x/y/width/height from that offset, its corner radius (grows slightly with offset so it always reads concentric with the card), and its stroke-dasharray, interpolating from loose stitches (8 4) at dp=0 to a taut near-solid line (1 0) at dp=1. Stroke color is a literal crossfade: two overlapping rects, one --muted one --foreground, whose opacities are 1-dp and dp respectively — never a JS color-lerp. Because the upload driver reports progress in irregular chunks (not a smooth ramp) every ~170-280ms, and the spring re-targets on every chunk without ever fully settling between them, the outline visibly breathes toward the card rather than snapping in discrete steps. On arrival at progress 1 the file's status flips to 'sealed' and the card plays one one-shot CSS keyframe — scale(1) to scale(0.99) to scale(1) over 260ms — a physical click, not a fade. A failed upload's spring instead retargets to dp=0 (loose/open) with light underdamping (zeta≈0.4, lower k) so it visibly overshoots past the 24px rest point and wobbles before settling — the exact same spring engine, just less damped, its rects redrawn in a single dashed var(--error, #ea001d) stroke rather than the muted/foreground pair. Multiple files each get their own card, own spring, own outline; nothing is shared or queued. Files pre-supplied via defaultFiles (with an explicit status and, for 'uploading', a progress) render in that exact end state at mount with no entrance spring at all — their geometry is written once, synchronously, in a layout effect before first paint, so the idle screenshot is a deterministic gallery of a sealed file, a mid-upload file, and a failed file, not a random mid-animation frame. A consumer supplies an uploadFile(file, onProgress) => Promise function for real uploads (reject to fail the seal); omitting it runs a built-in simulated upload for prototyping. Accessibility: the seal SVG is aria-hidden and purely decorative; each card carries a real role=progressbar with continuously-updating aria-valuenow/aria-valuemax and an aria-valuetext that reads 'upload failed' or '58%' as appropriate (pull-model, not itself aria-live, so it never spams); a single shared role=status aria-live=polite line separately announces only at 25/50/75% and on the terminal sealed/failed outcome, never per-percent; and every card also carries a plain, non-live Geist Mono status line ('uploading 41.2 MB… 58%', '2.6 MB · sealed', 'upload failed · 184 KB') for sighted users, updating freely without triggering announcements. Under prefers-reduced-motion the spring is skipped entirely: dp snaps directly to the nearest quarter (0/0.25/0.5/0.75/1) on every progress update with no interpolation and no overshoot, the pucker keyframe is suppressed, and a failed upload snaps straight back to the loose 24px outline with no wobble — fully legible, just discrete. No canvas anywhere; the outline is real SVG measured against the card via ResizeObserver, matching the light and dark theme through --muted/--foreground/--border tokens and the --error status token with its established repo-wide #ea001d fallback.