File Upload Dropzone

£5

A drag-and-drop upload area with a real file input behind its button: dropped files land as rows on a stagger, the progress bar catches each one and counts up, and a finished upload collapses the bar into a check mark that draws itself.

Flip intermediate
6 more details
progress-barcount-upstaggerflip-layoutsvg-line-drawmicro-interaction
File Upload Dropzone - UI component preview

This element reads better at your own screen size than in the frame below:

About this component

An upload dropzone that shows every stage of the job. The zone's dashed hairline turns to the accent and its icon lifts the moment a file is dragged over it, and on release the files land as rows on a stagger while each progress bar sweeps out from the left to catch its row. The percentage counts up on tabular numerals, and when a file finishes the bar collapses into a check mark that draws itself into the space the bar leaves, so completion is one movement rather than a swap of icons.

Read the full effect overview

Every other state is built too. A file whose type or size the markup does not accept is rejected before it is ever sent and takes a red row naming the reason; an upload that fails offers Retry, which restarts that file alone. Removing a row lifts it out of the flow, collapses its height, and reflows the survivors with GSAP Flip so nothing jumps.

The demo's progress is simulated by a GSAP tween, and swapping in real upload progress is a single call: setUploader receives the file and a progress callback and returns a promise, so XMLHttpRequest upload progress events or a fetch stream drop straight in. Accepted types and the size ceiling are data attributes on the component root. Real buttons throughout, a progressbar role with aria-valuenow kept in step, and a polite live region announcing what landed, finished, failed or was removed. One HTML file, one stylesheet, one script, vanilla JavaScript, GSAP and the Flip plugin, no framework. Light and dark themes come from one set of custom properties switched with a single data-variant attribute.

What's included

12 items
  • Real drag-and-drop plus a real multiple file input behind a Choose files button, so it is one tab stop, not two
  • The zone's dashed hairline becomes the accent and the icon lifts on dragenter, settling back with an elastic ease
  • Rows land on a stagger while each progress bar scales out from the left to catch its row
  • Percentage counts up on tabular numerals and drives aria-valuenow on a progressbar role
  • Done collapses the bar and draws a check mark in with strokeDashoffset
  • Client-side validation from data-accept and data-max-size, with the reason written into the row
  • Failed uploads keep a Retry button that restarts that file alone; rejections do not offer one
  • Removing a row collapses it out of flow while GSAP Flip reflows the rest
  • Image files preview from URL.createObjectURL, revoked when the row goes
  • setUploader(fn) swaps the simulated progress for XHR or fetch upload events
  • Polite aria-live announcements for added, uploaded, failed and removed files
  • Light and dark themes on one set of custom properties

Perfect for

5 use cases
  • Document and asset uploads inside an admin or dashboard product
  • Support and claims forms where the buyer must see each attachment succeed or fail
  • Media libraries and CMS screens that accept several images at once
  • Onboarding and verification flows that reject the wrong file type before sending it
  • Any upload field where the current answer is a spinner and a page reload

How it works

4 sections

The drop landing

Entering and leaving the zone are counted rather than toggled, so crossing a child element inside the zone cannot flicker the highlight off. The landing itself is two movements sharing one stagger: the rows come up from sixteen pixels, and a moment later each progress track runs in from the left so the bar appears to catch its own row. A near miss outside the zone is swallowed too, which is what stops the browser navigating to the dropped file.

Progress, and replacing it

Progress is a single value from 0 to 100 that drives the bar, the percentage text and the announced value together, so the visual and the spoken number can never disagree. The demo's uploader simply animates that value. A buyer replaces it with one documented hook that receives the file and a progress callback and returns a promise: resolving marks the row done, rejecting moves it to the error state with the message shown in the row.

Done, error and retry

Completion collapses the track while the check mark scales up in the cell the percentage vacated and draws itself in, so the bar reads as becoming the check. Rejections are decided before any upload starts, from the accepted types and size limit on the component root, and carry no Retry because the file will never pass; a failed upload keeps Retry, which restarts that one file.

Removing a row

The departing row is pinned in place and taken out of the flow instantly, so its own collapse cannot drag its neighbours down; the survivors then slide into the gap while the list's height eases from its old value to its new one so the card does not jump. Under reduced motion the row is simply removed.

Plugins Flip
Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Paid UI element

Purchase to unlock the code.

Buying File Upload Dropzone opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects.

£5 Standard license, unlimited projects

Browse UI elements

Documentation

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for colours

  • assets/script.js: readable, commented source with an onReady guard

  • Real drag-and-drop and a real file input. The input stays in the DOM and does the work; the Choose files button fronts it, so the component is one tab stop rather than two.

  • The zone reacts before the drop. dragenter and dragleave are counted, not toggled, so crossing a child inside the zone cannot flicker the highlight off. The dashed hairline becomes the accent and the icon lifts, settling back on an elastic ease.

  • Progress you can replace in one call. window.fileDropzone.setUploader(fn) swaps the demo's simulated tween for your own XHR or fetch upload progress.

  • Every state built, not just the happy one. Uploading, done, rejected (non-retryable), failed (retryable) and removed, each with its own row treatment.

  • Removal reflows with Flip. The departing row leaves the flow and collapses; the survivors travel into the gap and the card's height is tweened rather than jumping.

  • Roles: role="list" on the list, role="progressbar" with aria-valuemin / aria-valuemax / aria-valuenow on each track, role="status" with aria-live="polite" on the announcement line, and real <button> elements for Choose files, Retry and Remove.

Quick Start

1. Add to your <head>:

Code snippet omitted: it ships with the download.

2. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

3. Copy the component markup from index.html into your page.

The script finds everything through data- attributes, never through class names, so you can restyle freely as long as these stay:

Attribute On Required
data-dropzone the component root yes
data-dropzone-zone the drop target yes
data-dropzone-list the <ul> the rows go into yes
data-dropzone-input the <input type="file"> for the button path
data-dropzone-browse the Choose files button for the button path
data-dropzone-icon the icon that lifts on dragover optional
data-dropzone-status the aria-live announcement line optional

Two options are read off the root:

Code snippet omitted: it ships with the download.

data-accept takes the same syntax as the input's own accept attribute — extensions, exact MIME types, and wildcards such as image/* — and an empty or absent value accepts everything. data-max-size is in bytes; omit it for no ceiling. Both are validated in the browser before anything is sent, which is a courtesy to the person uploading, not a security control: validate again on the server.

The row markup is generated by the script, so there is nothing to copy for it. The <ul> should ship empty.

The showcase toolbar at the top of index.html, its inline <style> and <script>, and the .stage wrapper are demo furniture, not part of the component; leave them behind.

Wiring Up Your Own Uploads

The demo's progress is simulated by a GSAP tween, which is why it needs no server. Swap in your transport with one call:

Code snippet omitted: it ships with the download.

item is { id, name, size, type, file }. file is the real File object when one exists. onProgress takes a number from 0 to 100 and is what moves the bar, the percentage and aria-valuenow together. Resolving marks the row done; rejecting moves it to the error state and shows the thrown message in the row, with a Retry button that restarts that one file.

The rest of the API:

Call What it does
fileDropzone.add(descriptors) Adds rows from plain objects ({ name, size, type, thumb }) with no File behind them. This is how the demo's Simulate drop control works
fileDropzone.setFailNext(true) Makes the next file added fail; the demo's Fail next toggle
fileDropzone.clear() Removes every row
fileDropzone.items The live Map of items, keyed by id

Keyboard & Accessibility

Key What it does
Tab / Shift+Tab Moves through Choose files, then each row's Retry (when it is showing) and Remove, in visual order
Enter / Space on Choose files Opens the file picker
Enter / Space on Retry Restarts that file's upload
Enter / Space on Remove Removes that row and reflows the list
  • The zone as a whole is not a button and takes no focus: it is a drop target, and the button inside it is the keyboard and pointer route to the same thing. The file input carries tabindex="-1" and aria-hidden="true" so it is not a second, invisible tab stop.
  • The zone carries an aria-label naming both routes. Each track is a progressbar labelled with its file name and its aria-valuenow is written on the same call that paints the bar, so the two can never disagree.
  • Remove and Retry are labelled with the file name (aria-label="Remove invoice.pdf", and a visually hidden "uploading invoice.pdf" after Retry), so they are unambiguous out of context.
  • A role="status" region with aria-live="polite" announces what was added, what finished, what failed and why, and what was removed.
  • prefers-reduced-motion: the same end states with durations at zero. Rows appear in place, progress resolves immediately, the check is drawn instantly and removal is instant. Nothing is disabled, and nothing is left hidden.
  • Hover treatments are inside @media (hover: hover) so a tap on a touch screen cannot pin one open.

How It Works

The drop landing. dragenter and dragleave maintain a depth counter rather than a boolean, because a drag crossing a child element inside the zone fires a dragleave that would otherwise turn the highlight off mid-drag. The landing is two tweens sharing one stagger: the rows rise from 16px on power3.out, and a moment later each progress track scales from scaleX: 0 with a left transform origin so the bar appears to run in underneath its own row. dragover and drop are also swallowed at window level, which is what stops a near miss from navigating the browser away to the dropped file.

Progress as a custom property. A value between 0 and 100 is written to --progress on the row, and the bar's width is calc(var(--progress) * 1%). The same function writes the percentage text and aria-valuenow, so one call keeps all three in step and the bar has no layout thrash from JavaScript-set widths.

The done morph. Completion tweens the track's height to zero while the check mark scales up in the cell the percentage has just vacated, and the check's path draws in from a strokeDashoffset equal to its own getTotalLength(). The two share a grid cell deliberately: the bar reads as becoming the check rather than being replaced by it.

Errors. Rejections are decided before anything is sent, from data-accept and data-max-size, and are marked data-retryable="false" because that file will never pass; an upload that fails is marked retryable. When an error is applied the row element itself is deliberately left out of the kill list, because a file rejected on arrival is still mid-landing and killing that tween would leave the row present in the DOM at zero opacity.

Removal. Flip.getState records the survivors first. The departing row is then pinned to its measured position as an absolutely positioned box, which takes it out of the flow in one frame so its own height collapse cannot drag its neighbours down with it; Flip.from animates the survivors into the gap while the list's height is tweened from its old value to its new one so the card does not snap shorter. Object URLs made for image previews are revoked as the row goes.

Customisation

  • Accepted types and the size ceiling: data-accept and data-max-size on the component root, plus the hint line and the input's own accept attribute.
  • The component's width is set by its container; the demo gives it 640px, which suits a panel in a form. It has no minimum of its own.
  • Row density: padding and min-height on .fu-row, and the gap on .fu-list.
  • The bar's thickness and the check's size: height on .fu-row__track and width/height on .fu-row__check.
  • Colours live in assets/style.css as custom properties per theme.
  • Easing and durations are the gsap.to / gsap.fromTo calls in assets/script.js; the simulated upload's duration is in the default uploader function and is irrelevant once you call setUploader.

Themes

Ships in two themes, light and dark (the demo defaults to dark). Pick one by setting the attribute:

Code snippet omitted: it ships with the download.

In your own page the attribute can live on the component's own wrapper instead of <body>; nothing in assets/script.js reads the theme name, so the CSS is the whole mechanism. The demo's toggle and the ?variant=light URL parameter only set that attribute.

Every colour the component and the demo ground use is a custom property in those two blocks, so re-valuing one short list is the whole of a rebrand:

--ground, --ground-2, --raised, --hover, --ink, --ink-2, --ink-3, --line, --line-strong, --accent, --accent-ink, --accent-text, --accent-wash, --accent-hover, --danger, --danger-text, --danger-wash, --success-text, --check, --track-bg, --zone-bg, --row-bg, --thumb-bg, --shadow, --font, --radius, --radius-sm, --radius-xs.

To add a third theme, copy either [data-variant="..."] block, rename the value and change the properties. No selector override is ever needed.

Requirements

  • GSAP 3.12+ plus the Flip plugin (demo uses 3.15.0)
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.

Your cart

Your cart is empty

The Vault £99

Everything in the catalogue, plus everything we release next.