File Upload Dropzone in React: react-dropzone, Preview, Progress
Build a polished React file upload dropzone with react-dropzone, instant image previews, and an XHR progress bar — no boilerplate, no guesswork.
Why react-dropzone Is Still the Right Call in 2026
You could roll a dropzone by hand — attach dragover, drop, and change listeners, manage dataTransfer.files, write the <input type="file"> dance yourself. Plenty of tutorials show you how. But after doing it twice you'll reach for react-dropzone and never look back. It's been maintained since 2014, currently at v14.x, and it handles every edge case you'd otherwise discover the hard way: rejected MIME types, directory drops, mobile tap-to-browse fallback, focus rings for keyboard users.
The library weighs in under 10 kB gzipped with zero runtime dependencies. That's not nothing, but it's not the size you'd expect given how much surface area it covers. In practice, the alternative — a bespoke drag-and-drop implementation that you have to maintain across React 19 upgrades — costs far more over a project's lifetime.
Worth noting: react-dropzone is headless. It gives you props and state, not styles. That means you're free to make it look however you want — which is exactly the right call for a UI library. Pair it with Tailwind, CSS Modules, or Empire UI components and you'll ship something that doesn't look like every other <input type="file"> on the internet.
Setting Up react-dropzone
Install it once and you're off:
npm install react-dropzone
# or
pnpm add react-dropzoneThe core API is a single useDropzone hook. You destructure getRootProps, getInputProps, and isDragActive from it, then spread those onto your container and <input>. That's it — drag-and-drop is wired.
import { useDropzone } from 'react-dropzone';
export function BasicDropzone() {
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] },
maxSize: 5 * 1024 * 1024, // 5 MB
multiple: true,
});
return (
<div
{...getRootProps()}
className={[
'flex flex-col items-center justify-center',
'w-full h-48 rounded-xl border-2 border-dashed',
'cursor-pointer transition-colors duration-200',
isDragActive
? 'border-violet-500 bg-violet-50 dark:bg-violet-950/30'
: 'border-gray-300 dark:border-gray-700 hover:border-violet-400',
].join(' ')}
>
<input {...getInputProps()} />
<p className="text-sm text-gray-500 dark:text-gray-400">
{isDragActive ? 'Drop it!' : 'Drag files here, or click to browse'}
</p>
</div>
);
}The accept object follows the MIME-type-to-extensions format that the File System Access API uses. Passing { 'image/*': ['.jpg', '.png'] } means react-dropzone filters on both MIME type and extension — so a .php file renamed to .jpg still gets rejected at the extension level. That dual check saved me from a support ticket exactly once in a real app.
File Previews with Object URLs
Once you have files, you want thumbnails. The browser's URL.createObjectURL() generates a local blob URL that you can drop straight into an <img> src. It's fast — no upload needed — and it revokes itself when you call URL.revokeObjectURL(). Do that cleanup or you'll leak memory for every file a user cycles through.
import { useCallback, useState, useEffect } from 'react';
import { useDropzone, FileWithPath } from 'react-dropzone';
interface PreviewFile extends File {
preview: string;
}
export function DropzoneWithPreviews() {
const [files, setFiles] = useState<PreviewFile[]>([]);
const onDrop = useCallback((accepted: File[]) => {
const withPreviews = accepted.map((f) =>
Object.assign(f, { preview: URL.createObjectURL(f) })
);
setFiles((prev) => [...prev, ...withPreviews]);
}, []);
// Revoke object URLs on unmount or when files change
useEffect(() => {
return () => files.forEach((f) => URL.revokeObjectURL(f.preview));
}, [files]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { 'image/*': [] },
});
const remove = (name: string) => {
setFiles((prev) => prev.filter((f) => f.name !== name));
};
return (
<div className="space-y-4">
<div
{...getRootProps()}
className={`h-40 rounded-xl border-2 border-dashed flex items-center justify-center cursor-pointer ${
isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
}`}
>
<input {...getInputProps()} />
<span className="text-gray-500 text-sm">Drop images here</span>
</div>
{files.length > 0 && (
<ul className="grid grid-cols-3 gap-3">
{files.map((f) => (
<li key={f.name} className="relative rounded-lg overflow-hidden">
<img
src={f.preview}
alt={f.name}
className="w-full h-28 object-cover"
// Avoid loading the image after it's removed from state
onLoad={() => URL.revokeObjectURL(f.preview)}
/>
<button
onClick={() => remove(f.name)}
className="absolute top-1 right-1 rounded-full bg-black/60 text-white text-xs px-2 py-0.5"
>
✕
</button>
<p className="text-xs text-gray-600 truncate px-1 py-0.5">{f.name}</p>
</li>
))}
</ul>
)}
</div>
);
}Honestly, the onLoad revocation trick in the <img> tag is something most tutorials skip. If you only revoke on component unmount, you're keeping that blob URL alive the entire session. For 20 images that's 20 × (file size) sitting in memory. Not catastrophic, but it adds up fast in an image-heavy upload flow.
One more thing — for non-image files like PDFs or ZIPs you won't have a visual preview. Show a file-type icon instead. The file.type string ('application/pdf', 'application/zip') gives you what you need to pick the right icon from whatever icon library you're already using.
XHR Upload with a Progress Bar
Fetch doesn't expose upload progress. That's not a rumor — the Fetch API spec deliberately omits it. So for progress reporting you're back to XMLHttpRequest, specifically the xhr.upload.onprogress event. It fires with loaded and total byte counts, and from there the math is trivial.
function uploadFile(
file: File,
onProgress: (pct: number) => void,
signal: AbortSignal
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const fd = new FormData();
fd.append('file', file);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300
? resolve()
: reject(new Error(`Upload failed: ${xhr.status}`));
xhr.onerror = () => reject(new Error('Network error'));
signal.addEventListener('abort', () => {
xhr.abort();
reject(new DOMException('Upload aborted', 'AbortError'));
});
xhr.open('POST', '/api/upload');
xhr.send(fd);
});
}Now wire that into your component state. Each file gets its own progress number so you can render individual bars:
const [progresses, setProgresses] = useState<Record<string, number>>({});
const controllers = useRef<Record<string, AbortController>>({});
const startUpload = async (file: PreviewFile) => {
const ac = new AbortController();
controllers.current[file.name] = ac;
try {
await uploadFile(
file,
(pct) =>
setProgresses((prev) => ({ ...prev, [file.name]: pct })),
ac.signal
);
setProgresses((prev) => ({ ...prev, [file.name]: 100 }));
} catch (err) {
if ((err as DOMException).name !== 'AbortError') {
console.error(err);
}
}
};
const cancelUpload = (name: string) => {
controllers.current[name]?.abort();
};The AbortController pattern gives you cancel-per-file for free. Users who drop 10 images and immediately decide they only want 3 will thank you. Quick aside: in Next.js 15 App Router, /api/upload needs to be a Route Handler that calls request.formData() — no bodyParser config needed, the runtime handles multipart by default.
For the progress bar UI itself, a simple <div> with a width driven by pct% is plenty. If you want something polished without building it yourself, browse components on Empire UI — there are animated bar variants that already handle the width transition with a smooth ease.
Validation: Size, Type, and Count
react-dropzone's built-in maxSize, minSize, maxFiles, and accept options handle the common cases. But sometimes you need custom logic — say, rejecting files whose names contain spaces, or capping the cumulative upload at 50 MB. For that you use the validator option.
const MAX_TOTAL_MB = 50;
const { getRootProps, getInputProps, fileRejections } = useDropzone({
maxFiles: 10,
maxSize: 10 * 1024 * 1024, // 10 MB per file
validator: (file) => {
const currentTotal = files.reduce((sum, f) => sum + f.size, 0);
if (currentTotal + file.size > MAX_TOTAL_MB * 1024 * 1024) {
return {
code: 'total-size-exceeded',
message: `Total upload would exceed ${MAX_TOTAL_MB} MB`,
};
}
return null; // null = accepted
},
});fileRejections is an array of { file, errors } objects — each errors entry has a code and message. Render those below your dropzone so users immediately know what failed and why. Don't hide rejections in a toast that auto-dismisses after 3 seconds; show them inline.
Look, MIME type spoofing is a real attack vector. Never trust file.type on the server. On the client it's fine for UX filtering, but your backend should validate the actual magic bytes. In Node.js, file-type (the npm package) reads the first few bytes and returns the real type. Use it.
Styling the Dropzone: Dark Mode, Drag State, and Disabled
A dropzone has at least four visual states you need to handle: idle, drag-active, drag-rejected (wrong file type), and disabled. react-dropzone gives you isDragActive, isDragReject, and isDragAccept booleans — plus the disabled option — so building conditional classes is straightforward.
const { getRootProps, getInputProps, isDragActive, isDragReject, isDragAccept } =
useDropzone({ accept: { 'image/*': [] }, disabled: isUploading });
const borderClass = isDragReject
? 'border-red-500 bg-red-50 dark:bg-red-950/30'
: isDragAccept
? 'border-green-500 bg-green-50 dark:bg-green-950/30'
: isDragActive
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/30'
: 'border-gray-300 dark:border-gray-700';
const disabledClass = isUploading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer';For glassmorphism dropzones — think dashboard upload widgets over a blurred gradient background — Empire UI's glassmorphism components give you the translucent surface for free. Replace the solid bg-* idle state with bg-white/10 backdrop-blur-md border-white/20 and the dropzone fits right into a glass-heavy layout without any extra work.
Accessibility matters here more than people think. The getRootProps spread already includes role="button", tabIndex={0}, and keyboard event handlers from react-dropzone. Don't override tabIndex to -1 — keyboard users need to reach the dropzone. Add a visible focus ring via focus-visible:ring-2 focus-visible:ring-violet-500 and you're WCAG AA compliant for keyboard interaction with 8px of visual indicator, well above the 3px minimum.
That said, test on actual screen readers. VoiceOver on macOS and NVDA on Windows both announce the <input type="file"> correctly when you spread getInputProps. The common mistake is wrapping everything in a <div> and forgetting the <input> entirely — then screen readers see a focusable div with no label.
Putting It All Together: Full Upload Component
Here's a production-ready composition that combines everything above — dropzone, previews, per-file progress bars, cancel buttons, and rejection messages. Slot it into any Next.js or Vite/React app.
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDropzone } from 'react-dropzone';
interface UploadFile extends File {
preview: string;
progress: number;
error?: string;
}
export function FileUploadDropzone() {
const [files, setFiles] = useState<UploadFile[]>([]);
const controllers = useRef<Map<string, AbortController>>(new Map());
useEffect(() => {
return () => files.forEach((f) => URL.revokeObjectURL(f.preview));
}, [files]);
const onDrop = useCallback((accepted: File[], rejected: any[]) => {
const enriched: UploadFile[] = accepted.map((f) =>
Object.assign(f, { preview: URL.createObjectURL(f), progress: 0 })
);
setFiles((prev) => [...prev, ...enriched]);
enriched.forEach((f) => upload(f));
}, []);
const upload = (file: UploadFile) => {
const ac = new AbortController();
controllers.current.set(file.name, ac);
const xhr = new XMLHttpRequest();
const fd = new FormData();
fd.append('file', file);
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
const pct = Math.round((e.loaded / e.total) * 100);
setFiles((prev) =>
prev.map((f) => (f.name === file.name ? { ...f, progress: pct } : f))
);
};
xhr.onload = () => {
const ok = xhr.status >= 200 && xhr.status < 300;
setFiles((prev) =>
prev.map((f) =>
f.name === file.name
? { ...f, progress: ok ? 100 : 0, error: ok ? undefined : 'Upload failed' }
: f
)
);
};
ac.signal.addEventListener('abort', () => xhr.abort());
xhr.open('POST', '/api/upload');
xhr.send(fd);
};
const remove = (name: string) => {
controllers.current.get(name)?.abort();
setFiles((prev) => prev.filter((f) => f.name !== name));
};
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } =
useDropzone({
onDrop,
accept: { 'image/*': [], 'application/pdf': ['.pdf'] },
maxSize: 10 * 1024 * 1024,
maxFiles: 8,
});
return (
<div className="w-full max-w-xl mx-auto space-y-4">
<div
{...getRootProps()}
className={`flex flex-col items-center justify-center h-48 rounded-2xl border-2 border-dashed transition-colors ${
isDragReject
? 'border-red-500 bg-red-50'
: isDragActive
? 'border-violet-500 bg-violet-50'
: 'border-gray-300 hover:border-violet-400'
} cursor-pointer`}
>
<input {...getInputProps()} />
<svg className="w-10 h-10 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
<p className="text-sm text-gray-500">
{isDragReject ? 'File type not allowed' : 'Drop files here or click to browse'}
</p>
<p className="text-xs text-gray-400 mt-1">Images & PDFs up to 10 MB each, max 8 files</p>
</div>
{fileRejections.length > 0 && (
<ul className="text-sm text-red-600 space-y-1">
{fileRejections.map(({ file, errors }) => (
<li key={file.name}>
<strong>{file.name}</strong>: {errors.map((e) => e.message).join(', ')}
</li>
))}
</ul>
)}
{files.length > 0 && (
<ul className="space-y-3">
{files.map((f) => (
<li key={f.name} className="flex gap-3 items-start bg-gray-50 rounded-xl p-3">
{f.type.startsWith('image/') ? (
<img src={f.preview} alt={f.name} className="w-14 h-14 rounded-lg object-cover shrink-0" />
) : (
<div className="w-14 h-14 rounded-lg bg-gray-200 flex items-center justify-center text-xs text-gray-500 shrink-0">
PDF
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">{f.name}</p>
<p className="text-xs text-gray-400">{(f.size / 1024).toFixed(0)} KB</p>
<div className="mt-2 h-1.5 w-full bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-violet-500 transition-all duration-300"
style={{ width: `${f.progress}%` }}
/>
</div>
{f.error && <p className="text-xs text-red-500 mt-1">{f.error}</p>}
</div>
<button onClick={() => remove(f.name)} className="text-gray-400 hover:text-red-500 text-lg leading-none">
×
</button>
</li>
))}
</ul>
)}
</div>
);
}That's a fully functional upload component in under 110 lines. No third-party UI library required, no CSS framework lock-in. Swap the Tailwind classes for CSS Modules or Vanilla Extract if your project calls for it — the logic is completely separate from the presentation.
If you want to drop this into a bigger form — say a multi-step onboarding flow — check out multi-step-form-react which covers how to manage state across steps with Zod validation. The file upload fits naturally as step 2 or 3. And if you need a visual style beyond flat white, the Empire UI component library has drag-and-drop surfaces styled for glassmorphism, neobrutalism, claymorphism, and more — all copy-paste ready.
FAQ
Yes. react-dropzone v14 is compatible with React 18 and 19. It uses standard hooks and no deprecated lifecycle methods, so you won't hit warnings during the upgrade. Just make sure you're on v14.x — v11 and below have peer dependency issues with React 18+.
The Fetch API doesn't expose upload progress events — that's a deliberate spec decision, not a browser bug. The xhr.upload.onprogress event is the only browser-native way to track how many bytes have been sent. If you're using axios, it wraps XHR and exposes onUploadProgress as a convenience option.
Never trust file.type or the file extension alone — both are trivially spoofable by renaming a file. On the server, read the first 4–12 bytes of the file buffer and compare them against known magic byte signatures. The file-type npm package does this for you and supports over 100 file formats.
Yes. The cleanest pattern is to wrap it in a Controller component and call field.onChange with the accepted files array. The react-dropzone onDrop callback fires synchronously, so you can call setValue or trigger directly from it. See the react-hook-form docs on controlled components for the boilerplate.