Skip to content

File Upload Dropzone

A real drag-and-drop file zone: the border marches and glows while files are over it, rows show thumbnails, progress from your own upload callback, retry and remove, and the limits in the hint are actually enforced. No dependencies.

DOM + CSS0 deps1 file · 22 KB TSXreduced-motion aware

Freeminimaleditorial
Preview

Props

10
Multiple files
No runtime dependencies
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties, DragEvent, PointerEvent } from "react";

type Status = "selected" | "uploading" | "done" | "error" | "rejected";

interface Item {
  id: number;
  file: File;
  status: Status;
  progress: number;
  message?: string;
  url?: string;
  leaving?: boolean;
}

export interface RejectedFile {
  file: File;
  reason: string;
}

const DEFAULT_ACCEPT = [".png", ".pdf"];
const DEFAULT_ACCENT = "#ef4444";
const COLOR_RE = /^(#[0-9a-f]{3,8}|(rgb|hsl)a?\([\d\s.,%/a-z]+\))$/i;
const EASE = "cubic-bezier(.2,.8,.2,1)";

const CSS = [
  "@keyframes fud-march{to{stroke-dashoffset:-26}}",
  "@keyframes fud-in{from{grid-template-rows:0fr;opacity:0;transform:translateY(-6px)}to{grid-template-rows:1fr;opacity:1;transform:none}}",
  "@keyframes fud-sheen{from{transform:translateX(-120%)}to{transform:translateX(320%)}}",
  "@keyframes fud-draw{from{stroke-dashoffset:18}to{stroke-dashoffset:0}}",
  "@keyframes fud-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.18)}100%{transform:scale(1);opacity:1}}",
  ".fud-input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;opacity:0}",
  ".fud-zone{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;border-radius:20px;padding:30px 24px 26px;text-align:center;cursor:pointer;user-select:none;-webkit-user-select:none;background:color-mix(in srgb,currentColor 3%,transparent);transition:background-color .25s,box-shadow .3s,transform .4s " + EASE + "}",
  ".fud-zone:hover{background:color-mix(in srgb,currentColor 5%,transparent)}",
  ".fud-zone[data-drag='true']{background:color-mix(in srgb,var(--fud-a) 9%,transparent);box-shadow:0 0 0 4px color-mix(in srgb,var(--fud-a) 16%,transparent),0 18px 50px -18px color-mix(in srgb,var(--fud-a) 55%,transparent);transform:scale(1.012)}",
  ".fud-zone[data-disabled='true']{opacity:.5;cursor:not-allowed}",
  ".fud-input:focus-visible + .fud-zone{outline:2px solid var(--fud-a)!important;outline-offset:3px;border-radius:20px!important}",
  ".fud-glow{position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0;transition:opacity .3s;background:radial-gradient(220px circle at var(--fud-x,50%) var(--fud-y,50%),color-mix(in srgb,var(--fud-a) 20%,transparent),transparent 70%)}",
  ".fud-zone:hover .fud-glow,.fud-zone[data-drag='true'] .fud-glow{opacity:1}",
  ".fud-rect{fill:none;stroke:currentColor;stroke-opacity:.28;stroke-width:1.5;stroke-dasharray:6 7;transition:stroke-opacity .25s}",
  ".fud-zone:hover .fud-rect{stroke-opacity:.5}",
  ".fud-zone[data-drag='true'] .fud-rect{stroke:var(--fud-a);stroke-opacity:1;animation:fud-march .8s linear infinite}",
  ".fud-icon{position:relative;margin-bottom:6px;color:color-mix(in srgb,currentColor 55%,transparent);transition:color .25s}",
  ".fud-zone:hover .fud-icon{color:currentColor}",
  ".fud-zone[data-drag='true'] .fud-icon{color:var(--fud-a)}",
  ".fud-sheet{transform-box:fill-box;transition:transform .5s cubic-bezier(.34,1.56,.64,1);transform:translateY(7px)}",
  ".fud-zone:hover .fud-sheet{transform:translateY(1px)}",
  ".fud-zone[data-drag='true'] .fud-sheet{transform:translateY(-6px) scale(1.04)}",
  ".fud-row{display:grid;grid-template-rows:1fr;animation:fud-in .38s " + EASE + " backwards;transition:grid-template-rows .28s cubic-bezier(.4,0,.2,1),opacity .22s,transform .28s}",
  ".fud-row[data-leaving='true']{grid-template-rows:0fr;opacity:0;transform:translateX(10px)}",
  ".fud-row>div{min-height:0;overflow:hidden;padding:3px 2px 0}",
  ".fud-btn{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;flex:none;border-radius:10px;border:0;background:transparent;color:color-mix(in srgb,currentColor 55%,transparent);cursor:pointer;transition:background-color .15s,color .15s,transform .15s}",
  ".fud-btn:hover{background:color-mix(in srgb,currentColor 9%,transparent);color:currentColor}",
  ".fud-btn:active{transform:scale(.9)}",
  ".fud-btn:focus-visible{outline:2px solid var(--fud-a)!important;outline-offset:-2px;border-radius:10px!important}",
  ".fud-bar{position:absolute;left:0;bottom:0;height:2px;overflow:hidden;background:var(--fud-a);transition:width .3s ease-out}",
  ".fud-bar::after{content:'';position:absolute;inset:0;width:40%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.7),transparent);animation:fud-sheen 1.1s linear infinite}",
  ".fud-check{stroke-dasharray:18;animation:fud-draw .4s .05s ease-out backwards}",
  ".fud-tick{animation:fud-pop .45s cubic-bezier(.34,1.56,.64,1) backwards}",
  ".fud-clear{height:40px;padding:0 8px;margin:-8px -8px -8px 0;border:0;background:transparent;font:inherit;font-size:12px;color:color-mix(in srgb,currentColor 55%,transparent);cursor:pointer;border-radius:8px;transition:color .15s}",
  ".fud-clear:hover{color:currentColor}",
  ".fud-clear:focus-visible{outline:2px solid var(--fud-a)!important;outline-offset:-2px;border-radius:8px!important}",
  "@media (prefers-reduced-motion:reduce){.fud-zone,.fud-sheet,.fud-row,.fud-btn,.fud-bar{transition:none!important;animation:none!important}.fud-row,.fud-check,.fud-tick{animation:none!important}.fud-zone[data-drag='true']{transform:none}.fud-zone[data-drag='true'] .fud-rect{animation:none}.fud-bar::after{display:none}}",
].join("\n");

function formatSize(n: number): string {
  if (n < 1024) return n + " B";
  if (n < 1024 * 1024) return (n / 1024).toFixed(n < 10240 ? 1 : 0) + " KB";
  return (n / (1024 * 1024)).toFixed(n < 10 * 1024 * 1024 ? 1 : 0) + " MB";
}

function extOf(name: string): string {
  const i = name.lastIndexOf(".");
  return i > 0 && i < name.length - 1 ? name.slice(i + 1, i + 5).toUpperCase() : "FILE";
}

function hueFor(file: File): string {
  const e = extOf(file.name).toLowerCase();
  if (file.type.startsWith("image/")) return "#38bdf8";
  if (e === "pdf") return "#ef4444";
  if (["zip", "rar", "7z", "gz", "tar"].includes(e)) return "#f59e0b";
  if (["csv", "xls", "xlsx"].includes(e)) return "#22c55e";
  return "currentColor";
}

function matchesAccept(file: File, accept: string[]): boolean {
  if (!accept.length) return true;
  const name = file.name.toLowerCase();
  const type = file.type.toLowerCase();
  return accept.some((raw) => {
    const t = raw.trim().toLowerCase();
    if (!t) return false;
    if (t.startsWith(".")) return name.endsWith(t);
    if (t.endsWith("/*")) return type.startsWith(t.slice(0, -1));
    return type === t;
  });
}

export function FileUploadDropzone({
  hint = "PNG, PDF up to 10MB",
  accept = DEFAULT_ACCEPT,
  maxMb = 10,
  maxBytes,
  onFiles,
  onUpload,
  onReject,
  multiple = true,
  disabled = false,
  accent = DEFAULT_ACCENT,
  className = "",
}: {
  hint?: string;
  /** Extensions (".png") or MIME types ("image/*") actually enforced. Keep in step with `hint`. */
  accept?: string[];
  /** Size ceiling in megabytes. Keep this in step with the hint. */
  maxMb?: number;
  /** Exact byte ceiling. Overrides maxMb when given. */
  maxBytes?: number;
  /** Fires with the newly accepted File[] each time files are added. */
  onFiles?: (files: File[]) => void;
  /**
   * Optional. Do the real upload for one file and call `report(0..100)` as it
   * progresses. Resolve when finished, reject (or throw) to show a failed row
   * with Retry. `signal` aborts when the row is removed or cancelled.
   * Without it the component only LISTS selected files and says so.
   */
  onUpload?: (file: File, report: (percent: number) => void, signal: AbortSignal) => Promise<void>;
  /** Fires with files that failed the type or size check. */
  onReject?: (rejected: RejectedFile[]) => void;
  multiple?: boolean;
  disabled?: boolean;
  /** Drag, focus and progress colour (hex, rgb() or hsl()). */
  accent?: string;
  className?: string;
}) {
  const sizeLimit = maxBytes ?? maxMb * 1024 * 1024;
  const color = COLOR_RE.test(accent) ? accent : DEFAULT_ACCENT;
  const clipId = "fudclip" + useId().replace(/[^a-zA-Z0-9]/g, "");
  const [items, setItems] = useState<Item[]>([]);
  const [dragging, setDragging] = useState(false);
  const [dragCount, setDragCount] = useState(0);
  const [note, setNote] = useState("");
  const zoneRef = useRef<HTMLDivElement>(null);
  const depth = useRef(0);
  const nextId = useRef(1);
  const onUploadRef = useRef(onUpload);
  const ctrls = useRef(new Map<number, AbortController>());
  const urls = useRef(new Map<number, string>());
  const timers = useRef(new Set<number>());

  useEffect(() => {
    onUploadRef.current = onUpload;
  });

  useEffect(() => {
    const c = ctrls.current;
    const u = urls.current;
    const t = timers.current;
    return () => {
      c.forEach((ctl) => ctl.abort());
      c.clear();
      t.forEach((id) => window.clearTimeout(id));
      t.clear();
      u.forEach((url) => URL.revokeObjectURL(url));
      u.clear();
    };
  }, []);

  const patch = (id: number, p: Partial<Item>) =>
    setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...p } : it)));

  function run(id: number, file: File) {
    const fn = onUploadRef.current;
    if (!fn) return;
    const ctl = new AbortController();
    ctrls.current.set(id, ctl);
    patch(id, { status: "uploading", progress: 0, message: undefined });
    const report = (p: number) => {
      if (ctl.signal.aborted || !Number.isFinite(p)) return;
      patch(id, { progress: Math.max(0, Math.min(100, p)) });
    };
    Promise.resolve()
      .then(() => fn(file, report, ctl.signal))
      .then(
        () => {
          if (ctl.signal.aborted) return;
          ctrls.current.delete(id);
          patch(id, { status: "done", progress: 100 });
        },
        (err: unknown) => {
          if (ctl.signal.aborted) return;
          ctrls.current.delete(id);
          patch(id, { status: "error", message: err instanceof Error && err.message ? err.message : "Upload failed" });
        },
      );
  }

  function cancel(id: number) {
    const ctl = ctrls.current.get(id);
    if (!ctl) return;
    ctl.abort();
    ctrls.current.delete(id);
    patch(id, { status: "error", message: "Cancelled" });
  }

  function dropRows(ids: number[]) {
    if (!ids.length) return;
    ids.forEach((id) => {
      ctrls.current.get(id)?.abort();
      ctrls.current.delete(id);
    });
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const finish = () => {
      setItems((prev) => prev.filter((it) => !ids.includes(it.id)));
      ids.forEach((id) => {
        const u = urls.current.get(id);
        if (u) URL.revokeObjectURL(u);
        urls.current.delete(id);
      });
    };
    if (reduce) {
      finish();
      return;
    }
    setItems((prev) => prev.map((it) => (ids.includes(it.id) ? { ...it, leaving: true } : it)));
    const t = window.setTimeout(() => {
      timers.current.delete(t);
      finish();
    }, 290);
    timers.current.add(t);
  }

  function addFiles(list: File[]) {
    if (disabled || !list.length) return;
    const incoming = multiple ? list : list.slice(0, 1);
    const accepted: File[] = [];
    const rejected: RejectedFile[] = [];
    const rows: Item[] = [];
    for (const file of incoming) {
      const id = nextId.current++;
      let reason = "";
      if (!matchesAccept(file, accept)) reason = "Type not allowed (." + extOf(file.name).toLowerCase() + ")";
      else if (file.size > sizeLimit) reason = "Too large: " + formatSize(file.size) + ", limit " + formatSize(sizeLimit);
      if (reason) {
        rejected.push({ file, reason });
        rows.push({ id, file, status: "rejected", progress: 0, message: reason });
        continue;
      }
      let url: string | undefined;
      if (file.type.startsWith("image/")) {
        url = URL.createObjectURL(file);
        urls.current.set(id, url);
      }
      accepted.push(file);
      rows.push({ id, file, status: onUploadRef.current ? "uploading" : "selected", progress: 0, url });
    }
    if (!multiple) dropRows(items.map((it) => it.id));
    setItems((prev) => [...(multiple ? prev : []), ...rows]);
    rows.forEach((r) => {
      if (r.status === "uploading") run(r.id, r.file);
    });
    if (accepted.length) onFiles?.(accepted);
    if (rejected.length) onReject?.(rejected);
    setNote(
      (accepted.length ? "Added " + accepted.length + (accepted.length === 1 ? " file. " : " files. ") : "") +
        rejected.map((r) => r.file.name + " rejected: " + r.reason + ". ").join(""),
    );
  }

  const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");

  function onDragEnter(e: DragEvent) {
    if (disabled || !hasFiles(e)) return;
    e.preventDefault();
    depth.current += 1;
    setDragging(true);
    setDragCount(e.dataTransfer.items.length);
  }
  function onDragOver(e: DragEvent) {
    if (disabled || !hasFiles(e)) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = "copy";
  }
  function onDragLeave() {
    depth.current = Math.max(0, depth.current - 1);
    if (depth.current === 0) setDragging(false);
  }
  function onDrop(e: DragEvent) {
    e.preventDefault();
    depth.current = 0;
    setDragging(false);
    addFiles(Array.from(e.dataTransfer.files));
  }
  function onChange(e: ChangeEvent<HTMLInputElement>) {
    addFiles(Array.from(e.target.files ?? []));
    e.target.value = "";
  }
  function onMove(e: PointerEvent<HTMLLabelElement>) {
    const z = zoneRef.current;
    if (!z) return;
    const r = z.getBoundingClientRect();
    if (!r.width || !r.height) return;
    z.style.setProperty("--fud-x", (e.clientX - r.left) * (z.offsetWidth / r.width) + "px");
    z.style.setProperty("--fud-y", (e.clientY - r.top) * (z.offsetHeight / r.height) + "px");
  }

  const live = items.filter((it) => it.status !== "rejected" && !it.leaving);
  const uploading = live.filter((it) => it.status === "uploading").length;
  const done = live.filter((it) => it.status === "done").length;
  const n = live.length;
  const plural = (k: number) => k + (k === 1 ? " file" : " files");
  let summary = plural(n) + " selected, not uploaded";
  if (onUpload) summary = uploading ? plural(n) + ", " + uploading + " uploading" : done === n ? plural(n) + ", all uploaded" : plural(n);

  return (
    <>
      <style>{CSS}</style>
      <div className={"w-full max-w-sm " + className} style={{ "--fud-a": color } as CSSProperties}>
        <label
          onDragEnter={onDragEnter}
          onDragOver={onDragOver}
          onDragLeave={onDragLeave}
          onDrop={onDrop}
          onPointerMove={onMove}
          className="block"
        >
          <input
            type="file"
            multiple={multiple}
            disabled={disabled}
            accept={accept.join(",")}
            className="fud-input"
            onChange={onChange}
          />
          <div ref={zoneRef} className="fud-zone" data-drag={dragging} data-disabled={disabled}>
            <span aria-hidden className="fud-glow" />
            <svg aria-hidden className="pointer-events-none absolute" style={{ inset: 1, width: "calc(100% - 2px)", height: "calc(100% - 2px)", overflow: "visible" }}>
              <rect className="fud-rect" x="0" y="0" width="100%" height="100%" rx="19" />
            </svg>
            <svg aria-hidden className="fud-icon" width="64" height="64" viewBox="0 0 64 64" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <defs>
                <clipPath id={clipId}>
                  <rect x="0" y="-20" width="64" height="62" />
                </clipPath>
              </defs>
              <g clipPath={"url(#" + clipId + ")"}>
                <g className="fud-sheet">
                  <rect x="19" y="10" width="26" height="32" rx="4" />
                  <path d="M32 33V19M26.5 24.5 32 19l5.5 5.5" />
                </g>
              </g>
              <path d="M10 38v8a5 5 0 0 0 5 5h34a5 5 0 0 0 5-5v-8" />
            </svg>
            <p className="m-0 text-[15px] font-medium leading-snug">
              {dragging ? (
                dragCount > 1 ? "Release to add " + dragCount + " files" : "Release to add the file"
              ) : (
                <>
                  Drag files here or <span style={{ textDecoration: "underline", textUnderlineOffset: 3, textDecorationColor: "color-mix(in srgb,currentColor 40%,transparent)" }}>browse</span>
                </>
              )}
            </p>
            <p className="m-0 text-xs" style={{ color: "color-mix(in srgb,currentColor 55%,transparent)" }}>
              {hint}
            </p>
          </div>
        </label>

        <div role="status" aria-live="polite" className="fud-input">
          {note}
        </div>

        {items.length > 0 && (
          <div className="mt-3">
            <div className="mb-1 flex items-center justify-between px-0.5 text-xs" style={{ color: "color-mix(in srgb,currentColor 55%,transparent)" }}>
              <span>{summary}</span>
              <button type="button" className="fud-clear" onClick={() => dropRows(items.map((it) => it.id))}>
                Clear all
              </button>
            </div>
            <ul aria-label="Files" className="m-0 flex list-none flex-col p-0">
              {items.map((it) => (
                <li key={it.id} className="fud-row" data-leaving={it.leaving ? "true" : undefined}>
                  <div>
                    <Row
                      it={it}
                      onRemove={() => dropRows([it.id])}
                      onCancel={() => cancel(it.id)}
                      onRetry={() => run(it.id, it.file)}
                    />
                  </div>
                </li>
              ))}
            </ul>
          </div>
        )}
      </div>
    </>
  );
}

function Row({ it, onRemove, onCancel, onRetry }: { it: Item; onRemove: () => void; onCancel: () => void; onRetry: () => void }) {
  const { file, status } = it;
  const bad = status === "error" || status === "rejected";
  const hue = hueFor(file);
  const pct = Math.round(it.progress);
  let meta = formatSize(file.size) + " · Selected";
  if (status === "uploading") meta = pct + "% of " + formatSize(file.size);
  else if (status === "done") meta = formatSize(file.size) + " · Uploaded";
  else if (bad) meta = it.message ?? "Failed";
  return (
    <div
      className="relative flex items-center gap-2.5 overflow-hidden rounded-xl py-1.5 pl-2 pr-1"
      style={{
        border: "1px solid " + (bad ? "color-mix(in srgb,#ef4444 45%,transparent)" : "color-mix(in srgb,currentColor 13%,transparent)"),
        background: bad ? "color-mix(in srgb,#ef4444 6%,transparent)" : "color-mix(in srgb,currentColor 3%,transparent)",
      }}
    >
      <span
        aria-hidden
        className="relative flex h-10 w-10 flex-none items-center justify-center overflow-hidden rounded-lg"
        style={{ background: "color-mix(in srgb,currentColor 7%,transparent)" }}
      >
        <svg width="28" height="34" viewBox="0 0 28 34" fill="none" style={{ color: hue }}>
          <path d="M5 3h12l7 7v19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
          <path d="M17 3v5a2 2 0 0 0 2 2h5" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
          <text x="13.5" y="25" textAnchor="middle" fontSize="7" fontWeight="700" fill="currentColor" style={{ letterSpacing: 0.2 }}>
            {extOf(file.name)}
          </text>
        </svg>
        {it.url && (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={it.url}
            alt=""
            className="absolute inset-0 h-full w-full object-cover"
            onError={(e) => {
              e.currentTarget.style.display = "none";
            }}
          />
        )}
      </span>
      <span className="min-w-0 flex-1">
        <span className="block truncate text-[13px] font-medium leading-tight">{file.name}</span>
        <span
          className="mt-0.5 flex items-center gap-1 truncate text-[11.5px] leading-tight"
          style={{ color: bad ? "#ef4444" : "color-mix(in srgb,currentColor 55%,transparent)" }}
        >
          {status === "done" && (
            <svg className="fud-tick" aria-hidden width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#22c55e" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
              <circle cx="6" cy="6" r="5.2" strokeWidth="1.2" />
              <path className="fud-check" d="M3.6 6.2 5.3 7.9 8.5 4.4" />
            </svg>
          )}
          <span className="truncate">{meta}</span>
        </span>
      </span>
      {bad && status === "error" && (
        <button type="button" className="fud-btn" aria-label={"Retry " + file.name} onClick={onRetry}>
          <svg aria-hidden width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
            <path d="M13.5 8a5.5 5.5 0 1 1-1.8-4.1M13.5 2.5v3h-3" />
          </svg>
        </button>
      )}
      <button
        type="button"
        className="fud-btn"
        aria-label={status === "uploading" ? "Cancel upload of " + file.name : status === "rejected" ? "Dismiss " + file.name : "Remove " + file.name}
        onClick={status === "uploading" ? onCancel : onRemove}
      >
        <svg aria-hidden width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
          <path d="M3 3l8 8M11 3l-8 8" />
        </svg>
      </button>
      {status === "uploading" && (
        <div
          role="progressbar"
          aria-label={"Uploading " + file.name}
          aria-valuemin={0}
          aria-valuemax={100}
          aria-valuenow={pct}
          className="fud-bar"
          style={{ width: Math.max(it.progress, 3) + "%" }}
        />
      )}
    </div>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

A real input type=file sits inside a label, so clicking, Space, Enter and the OS picker all work, and dropping reads the genuine DataTransfer files. Drag state is counted across child enter and leave events, so the zone does not flicker when the cursor crosses the text, and it only reacts to drags that carry files. While files are over it the dashed border marches (an 0.8s stroke-dashoffset loop), the sheet in the tray lifts out on a springy 0.5s ease, a soft ring and glow come up in the accent colour, and the copy counts the files you are about to add. At rest a light follows the pointer inside the zone. Every file is checked against accept (extensions like .png or MIME types like image/*) and the size limit from maxMb or maxBytes; failures appear as red rows with the reason and never reach onFiles. Passing files get a row with a type tile, or a real thumbnail for images through an object URL that is revoked when the row leaves or the component unmounts. Progress is yours: pass onUpload(file, report, signal) and call report(0..100) as bytes go out; resolve to finish, reject to show the error with a Retry button, and the signal aborts when the row is cancelled or removed. Without onUpload nothing is uploaded: rows say Selected and the header says not uploaded, so the list never claims more than happened. Rows are removed with a short collapse. The border, text and tints follow the current text colour, so the zone sits on a light or dark page without changes; only the accent is fixed. The checks are a UX gate, never a security boundary, so validate on the server too.

Curator’s note

File Upload Dropzone, rebuilt in October. The original was a dashed box and a list of names; this one has a marching border, upload rows with progress and retry, and needs nothing but React. The demo’s uploads are simulated and labelled as such.

Pairs well with

Pro
Pro