Components

File Upload Staging Area

Drop real files (or stage the samples - nothing leaves the page) and each tile runs its own interruptible state machine: queued → uploading → done, or → error frozen at the failure percent. Retry resumes from that percent - the arc never rewinds - and a freed slot immediately promotes the next queued tile, because concurrency is capped at 2 so the pipeline stays visible. Progress is jittered but honest: variable speed with the occasional stall like a real network, but it never moves backwards and only 100% completes - failures are decided up front at a hidden percent, not rolled per frame. Removing tiles (or Clear done) reflows the grid with a layout glide: survivors slide to their new spots while new tiles scale in. Long names middle-truncate with the full name on hover.

Drop files here or

Nothing is uploaded anywhere - the network is simulated

Stage some files - drop them, browse, or add the samples

Install

npx moumenlab add file-upload-staging

For AI

Open .md

Usage

"use client";

import { useRef } from "react";
import UploadStaging, { type UploadStagingHandle } from "./file-upload-staging";

// Without an `upload` prop the network is simulated - drop in and demo. Wire
// your real network layer through the adapter and the component keeps owning
// the queue, concurrency, retry and abort-on-remove:
//
//   <UploadStaging
//     concurrency={3}
//     upload={async ({ file }, { onProgress, signal }) => {
//       await new Promise<void>((resolve, reject) => {
//         const xhr = new XMLHttpRequest();
//         xhr.open("POST", "/api/upload");
//         xhr.upload.onprogress = (e) => onProgress((e.loaded / e.total) * 100);
//         xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(xhr.statusText)));
//         xhr.onerror = () => reject(new Error("network"));
//         signal.addEventListener("abort", () => xhr.abort());
//         const form = new FormData();
//         form.append("file", file!);
//         xhr.send(form);
//       });
//     }}
//   />

export const SAMPLE_FILES = [
  { name: "team-photo-offsite-2026.png", size: 2_482_000 },
  { name: "quarterly-report-final-v2-approved-signed.pdf", size: 842_000 },
  { name: "design-system-tokens.zip", size: 5_113_000 },
  { name: "launch-checklist.doc", size: 96_400 },
  { name: "billing-webhook-retry-timeout-investigation-notes.doc", size: 141_200 },
  { name: "hero-banner@2x.png", size: 3_926_000 },
  { name: "contracts-archive-2025.zip", size: 11_480_000 },
  { name: "roadmap.pdf", size: 388_000 },
];

export default function UploadStagingExample() {
  const staging = useRef<UploadStagingHandle>(null);
  const cursor = useRef(0);

  // Walk the pool so repeated clicks stage different files.
  const stageSamples = () => {
    const start = cursor.current;
    cursor.current = (start + 4) % SAMPLE_FILES.length;
    staging.current?.addFiles(
      Array.from({ length: 4 }, (_, i) => SAMPLE_FILES[(start + i) % SAMPLE_FILES.length]),
    );
  };

  return (
    <div className="flex w-full max-w-[22rem] flex-col items-center gap-3">
      {/* Default: simulated network. Other knobs - `concurrency` caps parallel
          transfers, `failRate` shapes the simulation (0-1), `morph={false}`
          snaps layout changes instead of gliding. */}
      <UploadStaging ref={staging} concurrency={2} failRate={0.25} />
      <button
        type="button"
        onClick={stageSamples}
        className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground transition-[background-color,scale] hover:bg-primary/85 active:scale-[0.96]"
      >
        Stage 4 sample files
      </button>
    </div>
  );
}

Story

  1. A design-system ticket

    This one was born inside a design system I was building at work. The ask sounded simple: a file upload staging area. But the screen it was heading for is one customers keep open all day, so a dropzone alone was never going to be the component.

  2. The tab that never closes

    The requirement that shaped everything: the staging area lives in a tab that mostly never closes. Customers keep working in it, so there is no refresh to flush state and no popup to open and close and quietly reset things. Files keep arriving while older ones are still settling, and the component has to keep its own pipeline honest for hours.

  3. Done and not-yet, at a glance

    Focus one was clarity: what is done and what is not yet, without reading. Every tile is its own little machine: queued waits gray, uploading draws the ring with a live percent, done gets a green edge and a check that draws itself in, and failed goes red saying exactly where it stopped: failed at 43%.

  4. Progress that never lies

    For "done" to mean anything, the ring has to be honest. Progress jitters like a real network and sometimes stalls a beat, but it never moves backwards, and 100 is the only way to complete. Failures are decided up front, not rolled every frame, so the same file fails at the same point.

  5. Retry resumes, never rewinds

    In a long-lived tab a failure can't be a dead end. Retry re-queues the tile and resumes from the frozen percent: the arc never rewinds, because an arc that rewinds tells the customer their progress was fake. Concurrency stays capped at 2, so the pipeline reads as a pipeline instead of everything blasting to 100 at once.

  6. Batch verbs for a long day

    Focus two: after a few hours the grid is full of history, so the footer got verbs, not icons. Retry failed re-queues every red tile at once, Clear done sweeps the green ones away, and the survivors glide into their new spots instead of jump-cutting. The list keeps living; the tab stays open.

References

  • Layout animations in Motion: the layout prop behind the grid: on any list change the surviving tiles glide to their new spots instead of jump-cutting.
  • AbortController (MDN): how removal stays honest in real mode: deleting a tile mid-flight aborts the actual transfer, not just the tile.