File drop zone
Free · MITDrag-and-drop zone whose border pulses while files hover, then lists files with type icons, springy progress bars, size and type errors, and undo on remove.
Installation
$ npx shadcn@latest add https://goodui.dev/r/file-drop-pulse.jsonThe file lands in components/goodui/file-drop-pulse.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/file-drop-pulse works too.
Usage
import { FileDropPulse } from "@/components/goodui/file-drop-pulse"
export function ReceiptUpload({ expenseId }: { expenseId: string }) {
return (
<FileDropPulse
accept="image/*,.pdf"
maxSize={10 * 1024 * 1024}
maxFiles={5}
title="Drop receipts here or browse"
upload={(file, onProgress, signal) =>
new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open("POST", "/api/expenses/" + expenseId + "/receipts")
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress((e.loaded / e.total) * 100)
xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error("Server rejected " + file.name)))
xhr.onerror = () => reject(new Error("Network error"))
signal.addEventListener("abort", () => xhr.abort())
const body = new FormData()
body.append("file", file)
xhr.send(body)
})
}
onFilesChange={(items) => console.log(items.filter((i) => i.status === "done").length, "receipts uploaded")}
/>
)
}When to use
- Uploading documents to a record: receipts to an expense, contracts to a client, photos to a job.
- Imports where file type and size must be checked before anything is sent: CSV client lists, PDF invoices.
- Forms where users add several files and need to see each one's progress and remove mistakes.
- A single avatar or logo picker with a crop step: use an avatar with a plain file input instead.
- Pasting text or links: use input or tag-input.
| If you need | Use |
|---|---|
| Several files with a cap | multiple (default) with maxFiles |
| Exactly one file that a new drop replaces | multiple={false} |
| Restrict types | accept="image/*,.pdf,.csv" (MIME types, wildcards or extensions) |
| Restrict size | maxSize in bytes, e.g. 10 * 1024 * 1024 |
Examples
Add files from elsewhere (paste, another button)
import { FileDropPulse, type FileDropPulseHandle } from "@/components/goodui/file-drop-pulse"
const drop = React.useRef<FileDropPulseHandle>(null)
<FileDropPulse ref={drop} accept=".csv" multiple={false} hint="CSV export from your old booking tool" />
<button onClick={() => drop.current?.clear()}>Start over</button>
// drop.current?.addFiles(files) runs the same validation as a dropAPI reference
| Prop | Type | Default |
|---|---|---|
| title Main line in the drop zone; defaults to labels.title. | React.ReactNode | — |
| ref Imperative handle with addFiles(files) and clear(). | React.Ref<FileDropPulseHandle> | — |
| accept Same format as the input `accept` attribute: "image/*,.pdf,.csv". | string | — |
| maxSize Max size per file in bytes. | number | — |
| maxFiles Max files kept in the list; failed files do not count, extra files are skipped with a notice. | number | — |
| multiple Allow several files at once; when false a new file replaces the list (and cancels the replaced upload). | boolean | true |
| disabled | boolean | — |
| upload Upload one file and report progress 0–100. Defaults to a simulated upload. Reject (throw) to mark the file as failed. | (file: File, onProgress: (percent: number) => void, signal: AbortSignal) => Promise<void> | simulate |
| onFilesChange Called with the whole list whenever a file is added, progresses, finishes, fails or is removed (not on mount). | (items: UploadItem[]) => void | — |
| hint Second line in the drop zone; defaults to accept, max size and max files. | React.ReactNode | — |
| labels Text for built-in controls and screen readers; pass translations here. | Partial<FileDropPulseLabels> | — |
Also accepts every <div> attribute.
Accessibility
| Enter / Space | Opens the system file picker when the drop zone is focused. |
| Tab | Moves from the drop zone to each file's cancel/remove button and the Undo button. |
- The drop zone is a focusable role="button" that opens a hidden native file input (kept outside the zone, so there is no nested interactive content), with the hint linked through aria-describedby.
- Each uploading or finished file has a role="progressbar" with aria-valuenow; every row has a cancel or remove button named with the file name ("Cancel receipt.pdf").
- A finished file gets sr-only "Uploaded" text; the skipped-files notice is role="alert" and the undo bar is role="status".
- disabled sets aria-disabled, removes the zone from the tab order and ignores drops.
- labels translates every built-in string: progress, cancel, remove and uploaded, the default title, "Release to upload", the validation errors, the default hint parts, the skipped notice and the Undo bar.
Motion
- While files hover the zone, two rings pulse outward from its border every 1.3 s (CSS, offset by 0.65 s), the border turns solid primary, and the cloud icon lifts and bobs.
- New files rise in from 12px below on a spring (stiffness 420, damping 32); the list reflows with layout animation and removed files slide out sideways in 180 ms.
- Progress bars fill on a soft spring (stiffness 120, damping 22) and turn emerald with a popping check when done.
- Rejected files shake sideways for 400 ms.
- Reduced motion: one static ring instead of the pulse, no icon lift, no shake or layout animation; items fade in and out and progress bars jump.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- Without upload the component only simulates progress with random steps. Always pass upload in real apps and call onProgress with 0 to 100.
- Honor the AbortSignal in upload: it fires when the user cancels a file and on unmount. Throw (reject) to mark a file as failed; the error message is shown under the file.
- Validation happens before upload: wrong type or oversize files appear as failed rows and never call upload. Failed rows do not count toward maxFiles.
- onFilesChange receives the full list of UploadItem objects (file, progress, status, error). Read status === "done" to know what finished; it is not called with File[] and not on mount.
- With multiple={false} a new file replaces the current one even when maxFiles is set, and the replaced upload's AbortSignal fires.
- All built-in text comes from the labels prop (title, release, typeNotAllowed, tooLarge, skipped, upTo, maxFiles, removed, undo, plus the progress/cancel/remove strings); pass translations there.
- Removing a file that did not fail shows Undo for 5 seconds. Undo re-adds finished files as they were and restarts unfinished ones from zero, so upload may run twice for the same file.
FAQ
Does it upload files itself?
No. You pass an upload function (fetch, XHR, S3 presigned URL, UploadThing…) that reports progress. Without it the progress is simulated, for demos.
Can I show the uploaded files after a reload?
Not through this component: it only lists files added in the current session. Render saved files in your own list next to it.
Related
Six field styles with top, floating, inline or hidden labels, focus ring, glow, underline-grow or lift, error shake with sliding messages, icon and text addons, clear button, counter and auto-growing textarea.
Linear progress in four variants: spring-filled determinate, indeterminate double sweep, moving stripes and segmented steps.
Self-contained toaster with toast() API: cards stack and expand on hover, swipe away with a drag, morph from loading to success or error for promises, and pause their timers on hover.
Validation wrapper for any input that shakes and slides in an error with an icon when invalid, and draws a check circle when valid.