File Dropzone
A drag-and-drop upload area — the magnetic dashed border leans toward the cursor and marches like a marquee, and the file list carries ring progress indicators.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add file-dropzone.enFile Dropzone is a React drag and drop file upload component built with Tailwind CSS and Motion. While a file hovers, the dashed border shifts toward the cursor and flows, and the file list below shows each upload's status with an SVG progress ring.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<FileDropzone/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- Data import pages in an admin panel, such as uploading CSV, Excel or invoice PDFs.
- Profile photo or ID document uploads that allow a single image file.
- Cloud drive or asset library pages where uploading is the main action.
- Attachment fields in forms that need per-file progress, success and failure.
- Not a good fit: an attachment row above a chat input with thumbnail chips and drag reordering; use AI Attachment Tray instead.
Installation
npx webberui@latest add https://webberui.com/r/file-dropzone.en.jsonOr, once registries are configured in components.json, install it as @webberui/file-dropzone.
Usage
import { FileDropzone } from "@/components/ui/file-dropzone";
<FileDropzone
accept="image/*,.pdf"
maxFiles={4}
maxSize={10 * 1024 * 1024}
onFilesAdded={(files) => console.log(files)}
/>Wiring up a real upload
Without an upload prop, the component shows the animation with simulated progress; to connect a backend, pass an async function and report progress:
<FileDropzone
upload={async (file, onProgress) => {
await uploadToServer(file, {
onUploadProgress: (e) => onProgress(e.loaded / e.total),
});
}}
/>resolve shows the success checkmark; reject shows the failure state.
Examples
Upload to a presigned URL
Request a presigned URL from your backend, upload straight to object storage with XMLHttpRequest, and pass loaded / total to onProgress.
import { FileDropzone } from "@/components/ui/file-dropzone";
async function uploadToStorage(file: File, onProgress: (p: number) => void) {
const res = await fetch("/api/upload-url", {
method: "POST",
body: JSON.stringify({ name: file.name, type: file.type }),
});
const { url } = await res.json();
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total);
xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(String(xhr.status))));
xhr.onerror = () => reject(new Error("network"));
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", file.type);
xhr.send(file);
});
}
<FileDropzone accept=".csv,.xlsx" maxSize={20 * 1024 * 1024} upload={uploadToStorage} />Single avatar upload
With multiple={false} a new file replaces the old one; use onReject to tell users why a file was not accepted.
import * as React from "react";
import { FileDropzone, type FileDropzoneRejectReason } from "@/components/ui/file-dropzone";
const reasons: Record<FileDropzoneRejectReason, string> = {
type: "Please upload a JPG or PNG image",
size: "Images must be under 2 MB",
count: "You can upload only one image",
};
export function AvatarUpload() {
const [error, setError] = React.useState<string | null>(null);
return (
<div className="max-w-sm">
<FileDropzone
label="Drop your avatar here, or click to choose"
accept="image/jpeg,image/png"
multiple={false}
maxSize={2 * 1024 * 1024}
onFilesAdded={() => setError(null)}
onReject={(_, reason) => setError(reasons[reason])}
upload={(file, onProgress) => uploadAvatar(file, onProgress)}
/>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</div>
);
}Turn off the magnetic effect
strength={0} keeps the border still during a drag, which suits dense form layouts.
<FileDropzone strength={0} label="Drop the contract PDF" accept=".pdf" />Proximity magnet and following glow
magnet="proximity" listens for drags across the whole window instead: once a file is dragged within proximityRadius (180px by default) of the zone edge, the pull starts and grows as it gets closer (up to 10px, toward the cursor). The border scales gradually from 1 to 1.01, reaching 1.025 once inside the zone, while a blurred glow follows the cursor and fades in over 200ms. It suits pages with a single upload target where users should see where to drop as they drag.
<FileDropzone
magnet="proximity"
proximityRadius={220}
accept="image/*"
rejectMessage={(file, reason) =>
reason === "type" ? `${file.name} is not an image` : `${file.name} can't be added`
}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
onFilesAdded | (files: File[]) => void | — | Fires when files pass validation and are added to the list |
upload | (file: File, onProgress: (p: number) => void) => Promise<void> | — | Custom upload logic; onProgress reports 0–1. When omitted, progress is simulated |
onRemove | (file: File) => void | — | Fires when a file is removed from the list |
onReject | (file: File, reason: "type" | "size" | "count") => void | — | Fires when a file fails validation |
accept | string | — | Accepted file types (same as the native accept); validation applies to drops as well |
multiple | boolean | true | Allow multiple files; when false, a new file replaces the old one |
maxFiles | number | — | Maximum number of files in the list |
maxSize | number | — | Maximum size of a single file (bytes) |
strength | number | 0.12 | Magnetic strength; border offset = the cursor's distance from centre × strength (center mode only) |
magnet | "center" | "proximity" | "center" | Magnet mode: center offsets only while hovering; proximity pulls as a dragged file approaches and shows a following glow |
proximityRadius | number | 180 | Sensing radius for proximity mode (px); within this distance from the zone edge the pull grows linearly |
rejectMessage | (file: File, reason: "type" | "size" | "count") => string | — | Custom text announced to screen readers when validation fails; uses built-in messages when omitted |
label | string | "Drag and drop files here, or click to select" | Main text of the drop area |
disabled | boolean | false | Disables the whole drop area |
How it works
- Magnetic border: while a drag hovers, the dashed border offsets toward the cursor on a spring (capped at 10px) and scales up slightly, while the dashes march like a marquee
- Dragging in and out of child elements fires
dragenter/dragleaverepeatedly, so a depth counter internally prevents the border state from flickering - The progress ring advances smoothly through SVG
stroke-dashoffsetdriven by a spring, popping a checkmark on completion and showing a warning on failure acceptvalidation supports extensions (.pdf), wildcards (image/*), and full MIME types, and applies to both drops and picks- The input value is cleared after a selection, so the same file can be picked again
proximitymode listens fordragoveronwindowand measures the shortest distance from the cursor to the zone's rectangle; the pull changes linearly within the sensing radius, and offset and scale follow a spring withstiffness: 280,damping: 24,mass: 0.65, returning to rest when the drag leaves the window, drops, or is cancelled
Accessibility
- The drop area is
role="button", takes Tab focus, and opens the file picker with Enter / Space - The progress ring carries
role="progressbar"andaria-valuenow, and the success / failure icons have matchingaria-labels - The number of files uploading is announced to screen readers through
aria-live="polite" - Validation failures (type / size / count) and upload failures are announced immediately through a separate
aria-live="assertive"region; customise the text withrejectMessage - When the user has "reduce motion" enabled at the system level, the magnetic offset, marquee, and enter/exit animations are disabled, and the progress ring jumps straight to the current value;
proximitymode attaches no window listener and shows no glow
FAQ
What happens if I don't pass upload?
The component simulates upload progress with a timer so you can preview the animation; no request is sent. In production pass upload: a resolved promise shows the success check and a rejected one shows the error state.
Does it work with the Next.js App Router?
Yes. The source starts with "use client", so import it from a Server Component page as usual. Your upload function can call a Route Handler or a Server Action.
How does accept validation work?
It supports extensions (.pdf), wildcards (image/*) and full MIME types (image/png), separated by commas. Files are checked in order for type, size (maxSize, in bytes) and count (maxFiles), for both picking and dropping, and onReject fires for any that fail.
Is it usable with a keyboard and screen readers?
Yes. The drop area is a role="button": Tab to it and press Enter or Space to open the file picker. Progress rings have role="progressbar", and the number of uploading files is announced through aria-live. With "reduce motion" enabled, the magnetic shift, marching border and enter/exit animations are turned off.
What are the dependencies?
motion, lucide-react, clsx and tailwind-merge. You can skip the CLI and copy the source from the Manual tab.