GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Drag and Drop File Upload UI: 9 Computed Dropzones

Dropped files turn into previews computed from their bytes: type, size and count drive the layout, images get pixel-read thumbnails, rejects bounce back with a

Auto-plays · click a tile to jump to its section · all nine in one zip

Every one of the nine builds below runs the same idea from a different angle: a file arrives through dragover/drop or through paste, and the screen never draws anything until it has actually computed something from that arrival — the file's type, its size, its metadata, or, for the one item where the file already finished loading earlier, the pointer position that now reorders it. Item 01 reads only the MIME prefix, item 02 reads a video's own duration and resolution, item 03 counts files against a fixed cap, and items 04 through 09 each isolate one more piece of that same computed layer — an aspect ratio, a read-progress ratio, a clipboard kind, a rejection reason, a byte-to-unit ladder, and a pointer-to-index inversion. Reading them in this order means the intake function that items 06 and 07 lean on already showed up quietly in item 01's drop handler.

Demo

All nine zones replay a fixed scenario off a performance.now() clock instead of Math.random() or a CSS @keyframes loop, so the same file lands in the same slot in the same order every time the page loads — the scenario lists (names, MIME types, byte sizes) are named array constants, and the thumbnails are seeded pattern canvases rather than real image files. A real dragover, drop, or paste on any zone stops that replay instantly and hands control to whoever is actually holding the file, and prefers-reduced-motion keeps every MIME check, byte conversion, and ratio calculation running while only dropping the transition that animates the settle.

Anatomy of a dropzone — dragover, drop, DataTransfer.files, and preventDefault

Why preventDefault has to run on two events, not one

A browser's default behavior for a dropped file is to navigate to it and show the raw bytes on screen, and that default fires on both dragover and drop — skip preventDefault on either one and the dropzone below never receives the file at all, because the tab has already left the page. Every item here calls it twice, once per event, before touching e.dataTransfer.

Why files come out of DataTransfer.files and not the file name

e.dataTransfer.files is a real FileList of File objects, each one carrying a browser-assigned type and size that the file's own name never had to agree with — a .png can arrive with an empty type, and a renamed executable can still claim image/png if a script built it that way. Every rejection or acceptance decision on this page reads File.type, never file.name.split('.').

One intake function for two entrances

Drag and paste look like different gestures, but the object landing in the handler is the same File: a drop event exposes it through dataTransfer.files, and a paste event exposes it through clipboardData.items after filtering for kind === 'file'. Item 06 below builds exactly that filter, and it hands its result to the same processing function a drop would call — the grid never learns which entrance a file came through.

Code per dropzone in nine steps

Paste any item's prompt from the zip into an AI coding tool and it rebuilds the same screen — the block under each item is trimmed straight out of that item's index.html, the same file that ships inside the password-protected download alongside a matching React version.

01Image drop — an instant preview drawn from an object URL the moment it lands

Why the MIME prefix check comes before anything else

Pulling the File out of a drop's DataTransfer.files[0] and checking its type for the image/ prefix decides in one line whether anything else in this function runs at all.

Image drop — an instant preview drawn from an object URL the moment it lands
Open in a new tab

01 · Image drop — an instant preview drawn from an object URL the moment it lands — Measured area 45.397% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

dropDataTransfer.filesURL.createObjectURL

What the numbers say

Cumulative area comes out widest of the nine at 45.4% (peak frame 35.2%, average intensity 48.3), moving on 11 of 23 frame-to-frame comparisons as the scripted drop cycles through three seeded patterns with a settle pause after each landing.

function intake(files) {
  var f = files && files[0];
  if (!f) return;
  var ok = f.type && f.type.indexOf('image/') === 0;   // File.type prefix check
  if (!ok) return;
  if (cur >= 0) { try { URL.revokeObjectURL(objUrls[cur]); } catch (e) {} }
  cur = (cur + 1) % PALETTES.length;
  drawPattern(PALETTES[cur]);
  var u = URL.createObjectURL(f);
  objUrls.push(u);
  chip.textContent = f.type + ' · ' + f.size + 'B';
  urlEl.textContent = 'blob:' + u.slice(-4);
  revEl.textContent = 'revoke candidates ' + objUrls.length;
}

02Video drop — a panel that reads metadata first and computes duration and resolution

Why metadata is read before playback

Hanging a video/-typed file's object URL on a video element and waiting for loadedmetadata before painting anything means the numbers on screen are read off the file, never guessed from its name or size alone.

Video drop — a panel that reads metadata first and computes duration and resolution
Open in a new tab

02 · Video drop — a panel that reads metadata first and computes duration and resolution — Measured area 34.735% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

File.typeURL.createObjectURLloadedmetadata

What the numbers say

Cumulative area reaches 34.7% (peak frame 33.5%, average intensity 48.8), moving on 8 of 23 comparisons as each of the three scenario files loads its metadata and then holds still while the bitrate line renders.

function showReal(f) {
  var u = URL.createObjectURL(f);
  var probe = document.createElement('video');
  probe.muted = true;
  probe.onloadedmetadata = function () {
    paint(f.name, f.type, probe.duration, probe.videoWidth, probe.videoHeight, f.size);
    URL.revokeObjectURL(u);
  };
  probe.onerror = function () { URL.revokeObjectURL(u); };
  probe.src = u;
}

03Multi-file — a group that counts entries and computes filled slots versus the remainder

Why counting first decides the whole grid

Flattening DataTransfer.files with Array.from turns a FileList into something .length can count directly, and that count is the only input the rest of this function needs.

Multi-file — a group that counts entries and computes filled slots versus the remainder
Open in a new tab

03 · Multi-file — a group that counts entries and computes filled slots versus the remainder — Measured area 18.115% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

DataTransfer.filesArray.fromCSS custom properties

What the numbers say

Cumulative area is 18.1% (peak frame 14.7%, average intensity 93.3), moving on 7 of 23 comparisons as the scripted bundles of 3, 5, then 8 files land a beat apart.

function intake(files) {
  var arr = Array.from(files);                       // array-like FileList -> array
  shown = arr.slice(0, MAX);
  var over = arr.length - Math.min(arr.length, MAX);  // overflow = length - MAX when positive
  filled = shown.length;
  var empty = MAX - filled;                           // remaining empty slots
  var slotLis = slotsEl.children;
  for (var i = 0; i < MAX; i++) {
    var li = slotLis[i];
    li.className = 'mf__slot' + (i < filled ? '' : ' is-empty');
  }
}

04Preview grid — reading aspect ratios and computing a gap-free layout

Why each tile carries its own ratio

Carrying each image's naturalWidth-to-naturalHeight ratio into a --ratio custom property before it ever reaches CSS aspect-ratio is what lets a landscape tile and a portrait tile sit in the same row without either one squashing the other.

Preview grid — reading aspect ratios and computing a gap-free layout
Open in a new tab

04 · Preview grid — reading aspect ratios and computing a gap-free layout — Measured area 26.197% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

naturalWidthnaturalHeightCSS custom properties

What the numbers say

Cumulative area is 26.2% (peak frame 12.7%, average intensity 39.9), moving on 7 of 23 comparisons as each of the six seeded images drops in and holds while the ratio label updates.

function addTile(p) {
  paint(srcCv, p.w, p.h, p.seed);
  var ratio = p.w / p.h;
  var el = document.createElement('div');
  el.className = 'pg__tile';
  el.style.setProperty('--ratio', ratio.toFixed(4));
  var cv = document.createElement('canvas');
  cv.width = 64; cv.height = Math.max(16, Math.round(64 / ratio));
  var cx = cv.getContext('2d');
  if (cx) cx.drawImage(srcCv, 0, 0, cv.width, cv.height);
  tagEl.textContent = 'ratio ' + gcd(p.w, p.h);
  unitEl.textContent = 'tiles ' + grid.children.length + ' · max ' + T_MAX + 'px';
}

function gcd(a, b) {
  var x = a, y = b;
  while (y) { var t = y; y = x % y; x = t; }
  return (a / x) + ':' + (b / x);
}

05Read progress — a bar measuring how fast the browser itself ingests the dropped file

Why the bar and the label draw one number

FileReader.readAsArrayBuffer genuinely reads the file into memory with no server anywhere in the path, and the number the bar draws comes from normalizing performance.now() against a fixed duration through an ease-out curve, then multiplying by the file's real size.

Read progress — a bar measuring how fast the browser itself ingests the dropped file
Open in a new tab

05 · Read progress — a bar measuring how fast the browser itself ingests the dropped file — Measured area 2.928% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

FileReaderProgressEventCSS custom properties

What the numbers say

Cumulative area is lowest of the nine at 2.9% (peak frame 2.7%, average intensity 108.5), moving on 18 of 23 comparisons — the busiest frame count here, since the bar and the numeric label redraw on every animation tick while the second file's cancellation scenario runs partway through.

function read(f, cancelHalfway) {
  reader = new FileReader();
  var total = f.size;
  reader.onprogress = function () {};   // real API — fires while the file is actually read
  reader.onload = function () {};
  reader.readAsArrayBuffer(f);          // real read (finishes in a few ms — the curve below is the display)

  var start = performance.now();
  var stopAt = cancelHalfway ? CANCEL_AT : 1;
  (function step(now) {
    var t = Math.min(1, (now - start) / DUR);
    var eased = 1 - Math.pow(1 - t, 3);      // ease-out cubic
    var p = Math.min(eased, stopAt);
    var loaded = Math.round(p * total);
    setP(p, p >= 1, cancelHalfway && p >= CANCEL_AT - 0.001);
    if (p < stopAt && t < 1) raf = requestAnimationFrame(step);
  })(start);
}

06Paste intake — routing clipboard files onto the same path as a drop

Why kind file is the only filter that matters

ClipboardEvent.clipboardData.items mixes text fragments and files in the same DataTransferItemList, so filtering for kind === 'file' before calling getAsFile() is what keeps a copied sentence from turning into a fake file chip.

Paste intake — routing clipboard files onto the same path as a drop
Open in a new tab

06 · Paste intake — routing clipboard files onto the same path as a drop — Measured area 30.958% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

pasteClipboardEvent.clipboardDataDataTransferItemList

What the numbers say

Cumulative area is 31.0% (peak frame 22.0%, calmest average intensity of the nine at 34.5), moving on only 6 of 23 comparisons — the lowest frame count here, since only the list entries themselves swap between the three scripted paste sets.

function fromClipboard(items) {
  var picked = [];
  var skipped = 0;
  for (var i = 0; i < items.length; i++) {
    var it = items[i];
    if (it.kind === 'file') {
      var f = it.getAsFile();
      if (f) picked.push(f);
    } else {
      skipped++;
    }
  }
  render(items.length, picked, skipped);
}

07Type rejection — a gate that computes the reason from MIME prefix matching and bounces the file

Why a rejection needs a sentence, not a color

Comparing File.type against an allow-list with startsWith instead of parsing a file extension is what lets report.pdf and notes.txt fail for a stated, spoken reason instead of just silently disappearing.

Type rejection — a gate that computes the reason from MIME prefix matching and bounces the file
Open in a new tab

07 · Type rejection — a gate that computes the reason from MIME prefix matching and bounces the file — Measured area 4.949% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

File.typestartsWitharia-live

What the numbers say

Cumulative area is 4.9% (peak frame 3.7%, average intensity 72.6), moving on 10 of 23 comparisons as the png-pdf-jpg-txt scenario cycles with a pause after each verdict.

function judge(type) { return type.startsWith(ALLOW); }   // File.type prefix check

function announce(f, ok) {
  if (ok) {
    accepted++;
    msgEl.textContent = f.name + ' accepted(' + f.type + ')';
  } else {
    rejected++;
    msgEl.textContent = f.name + ' rejected — must start with image/ (got ' + f.type + ')';
    if (!reduce) {
      zone.style.animation = 'none';
      void zone.offsetWidth;
      zone.style.animation = 'rtBounce ' + BOUNCE_MS + 'ms ease-out';
    }
  }
}

08Size computation — converting bytes into human units and comparing against a cap

Why the ladder divides by 1024 and floors

File.size divides by 1024 (Math.pow(2,10)) as many times as it takes to land under that threshold, and flooring at a fixed decimal count — never toFixed rounding — is what keeps the displayed number from drifting above the real byte count.

Size computation — converting bytes into human units and comparing against a cap
Open in a new tab

08 · Size computation — converting bytes into human units and comparing against a cap — Measured area 3.258% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

File.sizeMath.powtransition

What the numbers say

Cumulative area is 3.3% (peak frame 2.9%, average intensity 43.3), moving on 8 of 23 comparisons as the ring gauge lerps toward each of the four scenario files' ratio.

function fmt(bytes) {
  var v = bytes, i = 0;
  while (v >= K && i < 2) { v /= K; i++; }          // repeated 1024 division
  var f = Math.pow(10, DECIMALS);
  v = Math.floor(v * f) / f;                        // floor at DECIMALS place
  return v.toFixed(DECIMALS) + ' ' + ['B', 'KB', 'MB'][i];
}

function show(f) {
  var ratio = f.size / LIMIT;                       // ratio to the cap drives the ring and the number
  ringVal.style.strokeDashoffset = String(188.5 * (1 - Math.min(1, ratio)));
  card.classList.toggle('is-over-limit', over);
  card.classList.toggle('is-near-limit', !over && ratio >= 0.85);
}

09Reorder after upload — finishing by hand-arranging the completed chips

Why only finished chips can be grabbed

Only a chip whose read has finished can be lifted with pointerdown, and moving it compares the pointer's x against each slot's getBoundingClientRect midpoint to work out where in the array it should land.

Reorder after upload — finishing by hand-arranging the completed chips
Open in a new tab

09 · Reorder after upload — finishing by hand-arranging the completed chips — Measured area 10.101% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointerdowngetBoundingClientRecttransition

What the numbers say

Cumulative area is 10.1% (peak frame 5.8%, average intensity 137.5) — the brightest of the nine, since the whole chip row shifts at once whenever the array order changes — moving on 10 of 23 comparisons.

function insertionIndex(x) {
  var idx = order.length;
  for (var k = 0; k < order.length; k++) {
    var r = chips[order[k]].el.getBoundingClientRect();
    if (x < r.left + r.width / 2) { idx = k; break; }   // compare against each slot's midpoint
  }
  return idx;
}

function armDrag(el, self) {
  el.addEventListener('pointerdown', function (e) {
    if (chips[self] && chips[self].state !== 'done') return;   // locked while still reading
    el.setPointerCapture(e.pointerId);
  });
}

A file is data — MIME, bytes, and object URLs deciding the UI

Why the intake decision is a data check, not a look-and-feel check

None of the nine dropzones decides anything by looking at a thumbnail or a file icon — item 01 and item 07 both run the exact same one-line test, File.type compared against a prefix, and the only difference between "shows a preview" and "bounces with a reason" is which prefix that comparison targets. A drag drop image upload build that instead sniffed the file's extension string would accept a renamed .exe claiming to end in .png and reject a real photo saved without an extension — File.type is the value the browser itself assigned when the file was picked or dropped, and it is the only value here that a file name can't lie about.

Why revoking an object URL matters as much as minting one

URL.createObjectURL in item 01 and item 04 doesn't copy the file anywhere — it mints a browser-memory address that only this tab can resolve, and every mint that never gets revokeObjectURL'd stays reachable until the page unloads. Item 01's revoke candidates counter exists so that swapping ten previews in a row doesn't quietly pile up ten forgotten object URLs behind the one visible on screen; a drag and drop file upload example that skips this step still "works" in a five-minute demo and leaks memory in a real one.

Where it breaks — folder drops, huge images, memory cleanup, mobile

Why a dropped folder isn't a File at all

Dragging a folder instead of a file onto any of these nine zones hands DataTransfer.files an entry whose type is an empty string and whose size is often 0 — there is no folder-reading API behind File.type, so item 07's rejection gate treats it exactly like any other file that fails the image/ prefix test rather than crashing on it. A dropzone that assumes every dropped item is a single flat file will throw on the read step long before it gets anywhere near a rejection message.

Why item 04's aspect math still needs a floor

Item 04's thumbnail canvas inverts its height from T_MAX / ratio, and an unusually wide or unusually tall seeded image can push that computed height toward zero or past what the grid cell can hold — which is why the real formula clamps the result to a 16px floor before it ever reaches the canvas's height attribute. Drop that clamp and a panorama-shaped file collapses its own thumbnail into an invisible sliver.

Why click-to-pick has to exist next to drag

The HTML5 Drag and Drop API that every one of these nine zones listens for has no equivalent gesture on a touch screen, so each dropzone also overlays a hidden input[type=file] that a tap opens directly — without it, a file dropzone ui that only wires up dragover and drop is unusable on a phone no matter how well the desktop version behaves. The full nine-item source, both plain and React, sits behind qe2efx37 in the zip linked from this post, the same way the byte-to-unit ladder in a companion upload-progress post ships its own full build behind a password rather than as a copy-paste snippet.

Where these nine actually land

A product listing form's main image field, the attachment area on a job application, and a shared folder for team files are the three screens that reach for a dropzone most. Item 01's image drop fits a listing form where the upload needs an instant check, and item 03's multi-file view fits an application form that has to accept several files at once.

In a shared folder with a size cap, item 08's size computation shows right away how close an upload sits to that limit. All three screens share the same unknown: nobody can predict what a visitor will drop or how many files it'll be, so a structure that never assumes a count or format fits them without changes.

Slow the progress fill down and accessibility shows up alongside it

Variant What changes What it trades off
Rejection bounce duration Item 07's BOUNCE_MS from 240 to 120 (ms) 240ms makes the rejection clearly visible but feels slow when dropping files in quick succession; 120ms reacts instantly but the reason text is easier to miss before it fades
Preview thumbnail max dimension Item 04's T_MAX from 320 to 160 (px) 320px keeps photo detail easy to pick out but few tiles fit on screen at once; 160px shows many files at once at the cost of telling similar photos apart
Size display decimals Item 08's DECIMALS from one place to two One decimal reads fast but two different files can render as the identical number; two decimals compare precisely at the cost of a longer, busier label

What reduced motion turns off, item by item

prefers-reduced-motion: reduce removes travel, not computation, across all nine: item 01's landing spring and item 09's chip-shift transition both skip straight to their end state while the MIME check, the byte math, and the insertion-index math underneath still run in full. Item 07's rejection bounce animation is skipped, but the aria-live announcement of the rejection reason still fires — a screen-reader user gets the same sentence either way. Item 05's progress bar keeps animating its width because that width is the live --p value itself, not a decorative loop layered on top of it.

Questions that come up once a dropzone ships

Does a drag and drop file upload example need a library to work?

No — all nine builds here are vanilla HTML, CSS, and JavaScript with no framework, no CDN, and no upload transport code at all. dragover, drop, and paste are native browser events, File.type and File.size are native properties, and the only "library" involved is the seeded mulberry32 generator each demo uses so its scripted scenario replays identically on every load. Every item's complete index.html, plus a matching React version, ships inside the password-protected zip linked from this post.

Why does a drag and drop file upload ui need preventDefault on both dragover and drop?

Because the browser has two separate default behaviors to cancel, one per event — dragover's default is refusing to show a "drop allowed" cursor at all, and drop's default is navigating the tab to the dropped file. Calling preventDefault() only on drop still leaves the cursor looking wrong throughout the drag, and calling it only on dragover still lets the browser hijack the tab the instant the file lands.

What happens if I search for a drag and drop file upload codepen instead of building one?

A CodePen demo shows the same events this post walks through — dragover, drop, DataTransfer.files — but usually as a single flat file preview rather than the nine separate computed layers here (metadata, aspect ratio, progress ratio, rejection reasons, byte units, reorder index). Pasting this post's prompt into an AI coding tool reproduces any one of those nine directly, with the exact parameters and measurements in the table above to check the rebuild against.

Sources: MDN — HTML Drag and Drop API · MDN — DataTransfer · MDN — File.type · MDN — URL.createObjectURL

A drag-to-reorder list further down this pillar walks the same insertion-index math item 09 leans on, just applied to a plain list instead of upload chips, and the byte-to-unit ladder in item 08 shows up again wherever a progress loader has to spell out how much of a file has actually arrived. The --ratio custom property item 04 writes on every drop works the same way as a slider track whose thumb position is one live number fed straight into CSS instead of a stored value per possible stop — both let a stylesheet read a figure that JavaScript only had to compute once per event, not once per frame.

Enter the archive password

The password is inside this article. You will find it as you read.