What's Included
index.html: the demo page and the markup to copyassets/style.css: component styles, plain CSS custom properties for coloursassets/script.js: readable, commented source with anonReadyguardReal 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.
dragenteranddragleaveare 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"witharia-valuemin/aria-valuemax/aria-valuenowon each track,role="status"witharia-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"andaria-hidden="true"so it is not a second, invisible tab stop. - The zone carries an
aria-labelnaming both routes. Each track is aprogressbarlabelled with its file name and itsaria-valuenowis 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 witharia-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-acceptanddata-max-sizeon the component root, plus the hint line and the input's ownacceptattribute. - 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:
paddingandmin-heighton.fu-row, and thegapon.fu-list. - The bar's thickness and the check's size:
heighton.fu-row__trackandwidth/heighton.fu-row__check. - Colours live in
assets/style.cssas custom properties per theme. - Easing and durations are the
gsap.to/gsap.fromTocalls inassets/script.js; the simulated upload's duration is in the defaultuploaderfunction and is irrelevant once you callsetUploader.
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.