Building a drag-and-drop file uploader in React

Letting users drag files directly into your app feels more intuitive than clicking "Choose File" and navigating through folders. This tutorial covers two approaches to building a drag-and-drop file uploader in React: using native HTML5 APIs and using the react-dropzone library.
By the end, you will have working TypeScript code for both approaches, along with file validation, visual feedback, and image previews.
Prerequisites
- React 18+
- Node.js 18+
- TypeScript (examples use TypeScript, but JavaScript works with minor adjustments)
What you'll build
A reusable file upload component that:
- Accepts files via drag-and-drop or click
- Shows visual feedback during drag
- Validates file types and sizes
- Displays image previews
- Handles errors gracefully
Approach 1: Native HTML5 drag-and-drop
The HTML5 Drag and Drop API works in all modern browsers without any dependencies. This approach gives you full control but requires handling events manually.
Understanding drag events
Four events control drag-and-drop behavior:
| Event | Fires when | Required action |
|---|---|---|
dragenter | Drag enters element | Show visual feedback |
dragover | Drag hovers over element | Call e.preventDefault() |
dragleave | Drag leaves element | Remove visual feedback |
drop | Files are dropped | Call e.preventDefault(), process files |
Both dragover and drop require e.preventDefault(). Without it, the browser navigates to the dropped file instead of handling it in your app.
Step 1: Basic drop zone
Create a component that handles the four drag events:
import { useState, DragEvent } from 'react';
interface FileUploaderProps {
onFilesSelected: (files: File[]) => void;
}
export function FileUploader({ onFilesSelected }: FileUploaderProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
onFilesSelected(files);
};
return (
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
border: `2px dashed ${isDragging ? '#2196f3' : '#ccc'}`,
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
backgroundColor: isDragging ? '#e3f2fd' : '#fafafa',
transition: 'all 0.2s ease',
cursor: 'pointer',
}}
>
{isDragging ? (
<p>Drop files here...</p>
) : (
<p>Drag and drop files here</p>
)}
</div>
);
}Step 2: Add click-to-select
Users expect to click the drop zone to open a file dialog. Add a hidden input element:
import { useState, DragEvent, useRef, ChangeEvent } from 'react';
interface FileUploaderProps {
onFilesSelected: (files: File[]) => void;
accept?: string;
multiple?: boolean;
}
export function FileUploader({
onFilesSelected,
accept,
multiple = true
}: FileUploaderProps) {
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
onFilesSelected(files);
};
const handleClick = () => {
inputRef.current?.click();
};
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
onFilesSelected(files);
}
};
return (
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
border: `2px dashed ${isDragging ? '#2196f3' : '#ccc'}`,
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
backgroundColor: isDragging ? '#e3f2fd' : '#fafafa',
transition: 'all 0.2s ease',
cursor: 'pointer',
}}
>
<input
ref={inputRef}
type="file"
onChange={handleInputChange}
accept={accept}
multiple={multiple}
style={{ display: 'none' }}
/>
{isDragging ? (
<p>Drop files here...</p>
) : (
<p>Drag and drop files here, or click to select</p>
)}
</div>
);
}Step 3: Add file validation
Filter files by type and size before passing them to the callback:
interface FileUploaderProps {
onFilesSelected: (files: File[]) => void;
onFilesRejected?: (files: { file: File; reason: string }[]) => void;
accept?: string[];
maxSize?: number;
maxFiles?: number;
multiple?: boolean;
}
export function FileUploader({
onFilesSelected,
onFilesRejected,
accept,
maxSize = 10 * 1024 * 1024, // 10MB default
maxFiles = 10,
multiple = true,
}: FileUploaderProps) {
// ... drag state and refs ...
const validateFiles = (files: File[]) => {
const accepted: File[] = [];
const rejected: { file: File; reason: string }[] = [];
files.forEach((file) => {
// Check file count
if (accepted.length >= maxFiles) {
rejected.push({ file, reason: `Maximum ${maxFiles} files allowed` });
return;
}
// Check file type
if (accept && accept.length > 0) {
const fileType = file.type;
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
const isAccepted = accept.some((type) => {
if (type.startsWith('.')) {
return fileExtension === type.toLowerCase();
}
if (type.endsWith('/*')) {
return fileType.startsWith(type.replace('/*', '/'));
}
return fileType === type;
});
if (!isAccepted) {
rejected.push({ file, reason: `File type not accepted` });
return;
}
}
// Check file size
if (file.size > maxSize) {
rejected.push({
file,
reason: `File exceeds ${Math.round(maxSize / 1024 / 1024)}MB limit`
});
return;
}
accepted.push(file);
});
return { accepted, rejected };
};
const handleFiles = (files: File[]) => {
const { accepted, rejected } = validateFiles(files);
if (accepted.length > 0) {
onFilesSelected(accepted);
}
if (rejected.length > 0 && onFilesRejected) {
onFilesRejected(rejected);
}
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
handleFiles(files);
};
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
handleFiles(files);
}
};
// ... rest of component
}Complete native implementation
Here is the full component with all features:
import { useState, DragEvent, useRef, ChangeEvent } from 'react';
interface FileUploaderProps {
onFilesSelected: (files: File[]) => void;
onFilesRejected?: (files: { file: File; reason: string }[]) => void;
accept?: string[];
maxSize?: number;
maxFiles?: number;
multiple?: boolean;
}
export function NativeFileUploader({
onFilesSelected,
onFilesRejected,
accept,
maxSize = 10 * 1024 * 1024,
maxFiles = 10,
multiple = true,
}: FileUploaderProps) {
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const validateFiles = (files: File[]) => {
const accepted: File[] = [];
const rejected: { file: File; reason: string }[] = [];
files.forEach((file) => {
if (accepted.length >= maxFiles) {
rejected.push({ file, reason: `Maximum ${maxFiles} files allowed` });
return;
}
if (accept && accept.length > 0) {
const fileType = file.type;
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
const isAccepted = accept.some((type) => {
if (type.startsWith('.')) {
return fileExtension === type.toLowerCase();
}
if (type.endsWith('/*')) {
return fileType.startsWith(type.replace('/*', '/'));
}
return fileType === type;
});
if (!isAccepted) {
rejected.push({ file, reason: `File type not accepted` });
return;
}
}
if (file.size > maxSize) {
rejected.push({
file,
reason: `File exceeds ${Math.round(maxSize / 1024 / 1024)}MB limit`
});
return;
}
accepted.push(file);
});
return { accepted, rejected };
};
const handleFiles = (files: File[]) => {
const { accepted, rejected } = validateFiles(files);
if (accepted.length > 0) {
onFilesSelected(accepted);
}
if (rejected.length > 0 && onFilesRejected) {
onFilesRejected(rejected);
}
};
const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
handleFiles(files);
};
const handleClick = () => {
inputRef.current?.click();
};
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
handleFiles(files);
e.target.value = '';
}
};
const acceptString = accept?.join(',');
return (
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
border: `2px dashed ${isDragging ? '#2196f3' : '#ccc'}`,
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
backgroundColor: isDragging ? '#e3f2fd' : '#fafafa',
transition: 'all 0.2s ease',
cursor: 'pointer',
}}
>
<input
ref={inputRef}
type="file"
onChange={handleInputChange}
accept={acceptString}
multiple={multiple}
style={{ display: 'none' }}
/>
{isDragging ? (
<p style={{ margin: 0, color: '#2196f3' }}>Drop files here...</p>
) : (
<div>
<p style={{ margin: '0 0 8px 0' }}>
Drag and drop files here, or click to select
</p>
<p style={{ margin: 0, fontSize: '14px', color: '#666' }}>
Max {maxFiles} files, {Math.round(maxSize / 1024 / 1024)}MB each
</p>
</div>
)}
</div>
);
}Approach 2: Using react-dropzone
For production applications, react-dropzone handles edge cases you might not anticipate: keyboard navigation, screen reader support, nested dropzones, and browser quirks.
Installation
npm install react-dropzoneThe package includes TypeScript declarations. No additional @types package is needed.
Version note: This tutorial uses react-dropzone 14.3.8, which requires React 16.8 or higher.
Basic usage
The useDropzone hook returns everything you need:
import { useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
export function Dropzone() {
const onDrop = useCallback((acceptedFiles: File[]) => {
acceptedFiles.forEach((file) => {
console.log('File:', file.name, file.size);
});
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
return (
<div {...getRootProps()} style={{
border: '2px dashed #ccc',
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
cursor: 'pointer',
}}>
<input {...getInputProps()} />
{isDragActive ? (
<p>Drop the files here...</p>
) : (
<p>Drag files here, or click to select</p>
)}
</div>
);
}The getRootProps() function returns event handlers for the container. The getInputProps() function returns props for the hidden file input. Spread both onto their respective elements.
Adding file validation
react-dropzone provides built-in validation through configuration options:
import { useCallback } from 'react';
import { useDropzone, FileRejection } from 'react-dropzone';
export function ValidatedDropzone() {
const onDrop = useCallback((
acceptedFiles: File[],
fileRejections: FileRejection[]
) => {
// Handle accepted files
acceptedFiles.forEach((file) => {
console.log('Accepted:', file.name);
});
// Handle rejected files
fileRejections.forEach(({ file, errors }) => {
console.log('Rejected:', file.name);
errors.forEach((error) => {
console.log(' Reason:', error.message);
});
});
}, []);
const {
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
} = useDropzone({
onDrop,
accept: {
'image/png': ['.png'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/gif': ['.gif'],
},
maxSize: 5 * 1024 * 1024, // 5MB
maxFiles: 5,
});
const getBorderColor = () => {
if (isDragAccept) return '#00e676';
if (isDragReject) return '#ff1744';
if (isDragActive) return '#2196f3';
return '#ccc';
};
return (
<div {...getRootProps()} style={{
border: `2px dashed ${getBorderColor()}`,
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
cursor: 'pointer',
transition: 'border-color 0.2s ease',
}}>
<input {...getInputProps()} />
{isDragAccept && <p>Drop to upload...</p>}
{isDragReject && <p>File type not accepted</p>}
{!isDragActive && (
<div>
<p>Drag images here, or click to select</p>
<p style={{ fontSize: '14px', color: '#666' }}>
PNG, JPG, or GIF up to 5MB
</p>
</div>
)}
</div>
);
}Accept format
The accept prop uses MIME types as keys and file extensions as values:
accept: {
'image/*': [], // All image types
'image/png': ['.png'], // Only PNG
'text/csv': ['.csv'], // CSV files
'application/pdf': ['.pdf'], // PDF files
'application/vnd.ms-excel': ['.xls'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
}Note: MIME type detection varies between operating systems. CSV files report as text/plain on macOS but application/vnd.ms-excel on Windows. Include multiple MIME types when accepting formats like CSV.
Custom validation
For validation beyond file type and size, use the validator prop:
import { useDropzone, FileError } from 'react-dropzone';
function nameLengthValidator(file: File): FileError | null {
if (file.name.length > 50) {
return {
code: 'name-too-long',
message: 'File name must be 50 characters or fewer',
};
}
return null;
}
function imageValidator(file: File): FileError | null {
// Return null to accept, or an error object to reject
if (file.name.includes(' ')) {
return {
code: 'name-has-spaces',
message: 'File name cannot contain spaces',
};
}
return null;
}
const { getRootProps, getInputProps } = useDropzone({
validator: nameLengthValidator,
// Or combine validators:
// validator: (file) => nameLengthValidator(file) || imageValidator(file),
});Displaying accepted and rejected files
Show users what happened after they drop files:
import { useState, useCallback } from 'react';
import { useDropzone, FileRejection } from 'react-dropzone';
interface FileWithPreview extends File {
preview: string;
}
export function DropzoneWithFeedback() {
const [acceptedFiles, setAcceptedFiles] = useState<FileWithPreview[]>([]);
const [rejectedFiles, setRejectedFiles] = useState<FileRejection[]>([]);
const onDrop = useCallback((
accepted: File[],
rejected: FileRejection[]
) => {
const filesWithPreview = accepted.map((file) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
})
);
setAcceptedFiles(filesWithPreview);
setRejectedFiles(rejected);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { 'image/*': [] },
maxSize: 5 * 1024 * 1024,
});
return (
<div>
<div {...getRootProps()} style={{
border: '2px dashed #ccc',
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
cursor: 'pointer',
}}>
<input {...getInputProps()} />
{isDragActive ? (
<p>Drop files here...</p>
) : (
<p>Drag images here, or click to select</p>
)}
</div>
{acceptedFiles.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h4>Accepted files:</h4>
<ul>
{acceptedFiles.map((file) => (
<li key={file.name}>
{file.name} - {(file.size / 1024).toFixed(1)} KB
</li>
))}
</ul>
</div>
)}
{rejectedFiles.length > 0 && (
<div style={{ marginTop: '20px', color: '#d32f2f' }}>
<h4>Rejected files:</h4>
<ul>
{rejectedFiles.map(({ file, errors }) => (
<li key={file.name}>
{file.name}
<ul>
{errors.map((e) => (
<li key={e.code}>{e.message}</li>
))}
</ul>
</li>
))}
</ul>
</div>
)}
</div>
);
}Adding image previews
For image uploads, show thumbnails of selected files:
import { useState, useCallback, useEffect } from 'react';
import { useDropzone } from 'react-dropzone';
interface FileWithPreview extends File {
preview: string;
}
export function ImageDropzone() {
const [files, setFiles] = useState<FileWithPreview[]>([]);
const onDrop = useCallback((acceptedFiles: File[]) => {
setFiles(
acceptedFiles.map((file) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
})
)
);
}, []);
const { getRootProps, getInputProps } = useDropzone({
onDrop,
accept: { 'image/*': [] },
});
// Clean up object URLs to prevent memory leaks
useEffect(() => {
return () => {
files.forEach((file) => URL.revokeObjectURL(file.preview));
};
}, [files]);
const removeFile = (name: string) => {
setFiles((prev) => {
const fileToRemove = prev.find((f) => f.name === name);
if (fileToRemove) {
URL.revokeObjectURL(fileToRemove.preview);
}
return prev.filter((f) => f.name !== name);
});
};
return (
<div>
<div {...getRootProps()} style={{
border: '2px dashed #ccc',
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
cursor: 'pointer',
}}>
<input {...getInputProps()} />
<p>Drag images here, or click to select</p>
</div>
{files.length > 0 && (
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '10px',
marginTop: '20px',
}}>
{files.map((file) => (
<div key={file.name} style={{
position: 'relative',
width: '100px',
height: '100px',
}}>
<img
src={file.preview}
alt={file.name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: '4px',
}}
onLoad={() => {
// Revoke after image loads to free memory
// (Comment this out if you need preview for upload later)
}}
/>
<button
onClick={() => removeFile(file.name)}
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
width: '24px',
height: '24px',
borderRadius: '50%',
border: 'none',
backgroundColor: '#d32f2f',
color: 'white',
cursor: 'pointer',
fontSize: '14px',
}}
>
x
</button>
</div>
))}
</div>
)}
</div>
);
}The URL.createObjectURL() function creates a temporary URL for displaying the file. Call URL.revokeObjectURL() when the preview is no longer needed to free memory.
Uploading to a server
After collecting files, send them to your backend using FormData:
import { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
export function UploadDropzone() {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const uploadFiles = async (files: File[]) => {
setUploading(true);
setUploadProgress(0);
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const result = await response.json();
console.log('Upload complete:', result);
} catch (error) {
console.error('Upload error:', error);
} finally {
setUploading(false);
}
};
const onDrop = useCallback((acceptedFiles: File[]) => {
uploadFiles(acceptedFiles);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
disabled: uploading,
});
return (
<div {...getRootProps()} style={{
border: '2px dashed #ccc',
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
cursor: uploading ? 'not-allowed' : 'pointer',
opacity: uploading ? 0.6 : 1,
}}>
<input {...getInputProps()} />
{uploading ? (
<p>Uploading...</p>
) : isDragActive ? (
<p>Drop files to upload...</p>
) : (
<p>Drag files here to upload</p>
)}
</div>
);
}For tracking upload progress with XMLHttpRequest or axios, see your HTTP client's documentation on progress events.
Common pitfalls and solutions
File dialog opens twice
This happens in two scenarios:
Using <label> as the root element: The label's native click behavior conflicts with react-dropzone's click handler.
// Problem
<label {...getRootProps()}>
<input {...getInputProps()} />
</label>
// Solution: disable click handling
const { getRootProps, getInputProps } = useDropzone({ noClick: true });
<label {...getRootProps()}>
<input {...getInputProps()} />
</label>Using open() with a button inside the dropzone: The button click bubbles to the root element.
// Problem
<div {...getRootProps()}>
<button onClick={open}>Select Files</button>
</div>
// Solution: disable click on root
const { getRootProps, getInputProps, open } = useDropzone({ noClick: true });
<div {...getRootProps()}>
<input {...getInputProps()} />
<button type="button" onClick={open}>Select Files</button>
</div>MIME types differ between operating systems
CSV files report as text/plain on macOS but application/vnd.ms-excel on Windows. Accept multiple MIME types:
accept: {
'text/csv': ['.csv'],
'text/plain': ['.csv'],
'application/vnd.ms-excel': ['.csv'],
}Browser navigates to dropped file
This happens when e.preventDefault() is missing from dragover or drop handlers. With react-dropzone, this is handled automatically. With native implementation, ensure both events call preventDefault():
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); // Required
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); // Required
// ... handle files
};Memory leaks with image previews
Object URLs created with URL.createObjectURL() persist until explicitly revoked or the page is closed. Clean them up:
useEffect(() => {
// Clean up when component unmounts
return () => {
files.forEach((file) => URL.revokeObjectURL(file.preview));
};
}, [files]);File path not available
For security reasons, browsers do not expose file system paths. The File.path property will be empty. Use FileReader to access contents:
const reader = new FileReader();
reader.onload = () => {
const content = reader.result;
console.log(content);
};
reader.readAsText(file); // or readAsArrayBuffer, readAsDataURLComplete working example
Here is a full-featured file upload component using react-dropzone:
import { useState, useCallback, useEffect } from 'react';
import { useDropzone, FileRejection } from 'react-dropzone';
interface FileWithPreview extends File {
preview: string;
}
interface FileUploaderProps {
onFilesReady: (files: File[]) => void;
accept?: Record<string, string[]>;
maxSize?: number;
maxFiles?: number;
}
export function FileUploader({
onFilesReady,
accept = { 'image/*': [] },
maxSize = 5 * 1024 * 1024,
maxFiles = 10,
}: FileUploaderProps) {
const [files, setFiles] = useState<FileWithPreview[]>([]);
const [rejected, setRejected] = useState<FileRejection[]>([]);
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
const newFiles = acceptedFiles.map((file) =>
Object.assign(file, { preview: URL.createObjectURL(file) })
);
setFiles((prev) => [...prev, ...newFiles].slice(0, maxFiles));
setRejected(rejectedFiles);
onFilesReady([...files, ...acceptedFiles].slice(0, maxFiles));
},
[files, maxFiles, onFilesReady]
);
const {
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
} = useDropzone({
onDrop,
accept,
maxSize,
maxFiles: maxFiles - files.length,
disabled: files.length >= maxFiles,
});
useEffect(() => {
return () => {
files.forEach((file) => URL.revokeObjectURL(file.preview));
};
}, [files]);
const removeFile = (name: string) => {
setFiles((prev) => {
const updated = prev.filter((f) => f.name !== name);
const removed = prev.find((f) => f.name === name);
if (removed) URL.revokeObjectURL(removed.preview);
onFilesReady(updated);
return updated;
});
};
const getBorderColor = () => {
if (isDragAccept) return '#00e676';
if (isDragReject) return '#ff1744';
if (isDragActive) return '#2196f3';
return '#e0e0e0';
};
return (
<div>
<div
{...getRootProps()}
style={{
border: `2px dashed ${getBorderColor()}`,
borderRadius: '8px',
padding: '32px',
textAlign: 'center',
cursor: files.length >= maxFiles ? 'not-allowed' : 'pointer',
backgroundColor: isDragActive ? '#f5f5f5' : 'white',
transition: 'all 0.2s ease',
}}
>
<input {...getInputProps()} />
{isDragAccept && <p>Drop to add files</p>}
{isDragReject && <p style={{ color: '#d32f2f' }}>Some files will be rejected</p>}
{!isDragActive && (
<div>
<p style={{ margin: '0 0 8px' }}>
{files.length >= maxFiles
? 'Maximum files reached'
: 'Drag files here, or click to select'}
</p>
<p style={{ margin: 0, fontSize: '14px', color: '#666' }}>
{files.length} / {maxFiles} files selected
</p>
</div>
)}
</div>
{files.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
marginTop: '16px',
}}
>
{files.map((file) => (
<div
key={file.name}
style={{
position: 'relative',
width: '80px',
height: '80px',
}}
>
<img
src={file.preview}
alt={file.name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: '4px',
}}
/>
<button
type="button"
onClick={() => removeFile(file.name)}
style={{
position: 'absolute',
top: '-6px',
right: '-6px',
width: '20px',
height: '20px',
borderRadius: '50%',
border: 'none',
backgroundColor: '#d32f2f',
color: 'white',
cursor: 'pointer',
fontSize: '12px',
lineHeight: '1',
}}
>
x
</button>
</div>
))}
</div>
)}
{rejected.length > 0 && (
<div style={{ marginTop: '16px', color: '#d32f2f', fontSize: '14px' }}>
<p style={{ fontWeight: 'bold', margin: '0 0 8px' }}>Rejected files:</p>
{rejected.map(({ file, errors }) => (
<p key={file.name} style={{ margin: '4px 0' }}>
{file.name}: {errors.map((e) => e.message).join(', ')}
</p>
))}
</div>
)}
</div>
);
}When to use a dedicated CSV solution
The drag-and-drop file upload we built works for any file type: images, PDFs, documents, or CSV files. For general file uploads, this is all you need.
However, CSV imports typically require more than uploading. Users need to:
- Map CSV columns to your database fields
- Validate data types before import
- Handle and correct errors in the data
- Preview what will be imported
Building these features from scratch adds significant complexity on top of file upload.
If you're building a CSV import feature, ImportCSV provides a drop-in component that handles drag-and-drop, column mapping, validation, and error handling out of the box:
import { CSVImporter } from '@importcsv/react';
<CSVImporter
onComplete={(data) => {
console.log('Validated rows:', data);
}}
/>Related posts
Wrap-up
CSV imports shouldn't slow you down. ImportCSV aims to expand into your workflow — whether you're building data import flows, handling customer uploads, or processing large datasets.
If that sounds like the kind of tooling you want to use, try ImportCSV .