File Upload — <mv-file-upload>

File drop zone: drag and drop with highlighting (and visual rejection of disallowed types), a list with image thumbnails, icons, sizes and removal, type/size/count validation, and per-file progress; form-associated (FormData).

CategoryForms
TypeWeb Component (<mv-file-upload>)
Statusstable
Keywordsfile-upload, dropzone, drag-and-drop, upload, attachments, progress, form-associated

When to use

Avoid when

Install

node scripts/add.mjs file-upload --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["file-upload"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/file-upload/file-upload.js, components/file-upload/file-upload.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<form id="file-upload-demo-form" style="display:grid;gap:.5rem;width:min(100%,30rem)">
  <label for="file-upload-demo" style="font-size:.875rem;font-weight:500">Supporting documents</label>
  <mv-file-upload id="file-upload-demo" name="attachments" accept="image/*,.pdf,.docx,.xlsx" multiple max-size="5 MB" max-files="6"></mv-file-upload>
</form>
<script type="module">
  const up = document.getElementById("file-upload-demo");

  // Simulated upload: progress ticks until 100%, one in eight fails.
  let seeding = false;
  const simulate = (file, from = 0) => {
    let pct = from;
    const fail = Math.random() < 0.125;
    const tick = () => {
      if (!up.files.includes(file)) return;
      pct += 6 + Math.random() * 16;
      if (fail && pct > 55) return up.setError(file, "Connection lost");
      up.setProgress(file, Math.min(100, pct));
      if (pct < 100) setTimeout(tick, 180 + Math.random() * 260);
    };
    setTimeout(tick, 150);
  };
  up.addEventListener("mv-add", (e) => { if (!seeding) e.detail.files.forEach((f) => simulate(f)); });

  // Seed the demo with a few realistic files.
  customElements.whenDefined("mv-file-upload").then(async () => {
    const canvas = document.createElement("canvas");
    canvas.width = 160; canvas.height = 160;
    const ctx = canvas.getContext("2d");
    const g = ctx.createLinearGradient(0, 0, 160, 160);
    g.addColorStop(0, "#f6d365"); g.addColorStop(0.55, "#fda085"); g.addColorStop(1, "#8e7cc3");
    ctx.fillStyle = g; ctx.fillRect(0, 0, 160, 160);
    ctx.fillStyle = "rgba(255,255,255,.85)";
    ctx.beginPath(); ctx.arc(112, 48, 18, 0, Math.PI * 2); ctx.fill();
    ctx.fillStyle = "rgba(40,30,70,.55)";
    ctx.beginPath(); ctx.moveTo(0, 160); ctx.lineTo(56, 84); ctx.lineTo(96, 128); ctx.lineTo(124, 100); ctx.lineTo(160, 140); ctx.lineTo(160, 160); ctx.fill();
    const blob = await new Promise((r) => canvas.toBlob(r, "image/jpeg", 0.8));
    const pad = (n) => new Uint8Array(n);
    const photo = new File([blob, pad(1_380_000)], "site-facade.jpg", { type: "image/jpeg" });
    const quote = new File([pad(842_000)], "signed-quote-2026-09.pdf", { type: "application/pdf" });
    const plan = new File([pad(2_310_000)], "floor-plan-level-2.docx", { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" });
    seeding = true;
    const [a, b, c] = up.addFiles([photo, quote, plan]);
    seeding = false;
    up.setProgress(a, 100);
    up.setProgress(b, 100);
    up.setProgress(c, 64);
    setTimeout(() => simulate(c, 64), 2500);
  });
</script>

API

Attributes

NameTypeDefaultDescription
namestringName of the form entries (one per file).
acceptstringAccepted types, like <input type=file>: “image/*,.pdf,application/zip”.
multiplebooleanMultiple files (otherwise a new file replaces the previous one).
max-sizestringMax size per file: “5 MB”, “500KB”, “1048576”.
max-filesnumberMax number of files (with multiple).
labelstringMain text of the zone (default: “Drag and drop files or browse”).
hintstringHint text (generated default: types · size · count).
layoutlist | gridlistDetailed list or thumbnail grid.
requiredbooleanNative validation (valueMissing).
disabledbooleanDisables the zone.
localestringen-USSize formatting (Intl: “2.4 MB”).

Properties

NameTypeDescription
filesFile[]Kept files (read-only).
itemsArray<{ id, file, status, progress, error }>State of each file: idle | uploading | done | error.

Methods

NameDescription
open()Opens the native file picker.
addFiles(files)Validates and adds files; returns the accepted ones.
remove(file | id)Removes a file (and releases its preview).
clear()Clears the list.
setProgress(file | id, pct)Updates the progress bar (100 = uploaded).
setError(file | id, message)Marks a file as failed.
checkValidity() / reportValidity()Native validation.

Events

NameDescription
mv-adddetail: { files } — accepted files (hook point to start the upload).
mv-removedetail: { file }.
mv-changedetail: { files } — full list after an add/remove.
mv-rejectdetail: { errors: [{ file, reason: type | size | count, message }] }.

Content structure

NameDescription
child contentReplaces the default content of the drop zone.

CSS classes

NameDescription
mv-file-upload-zone / -item / -thumb / -progress / -errorsGenerated parts; data-dragging / data-drag-reject on the host while dragging.

CSS variables

NameDefaultDescription
--mv-file-upload-width32remMax width.
--mv-file-upload-zone-height10remMin height of the zone.
--mv-file-upload-radiusvar(--mv-radius-xl)Zone radius.

Accessibility

The zone is a focusable role=button (Enter/Space open the picker), named by the <label for> and described by the hint text. Validation errors go in a role=alert region; additions, removals and completed uploads are announced through a polite live region. Each file has a “Remove <name>” button (focus moves to the next one) and a role=progressbar bar while uploading.