Overview
The File Upload component enables users to select and upload files from their device to a server. It typically manifests as either a clickable button that opens the system file picker or a drag-and-drop zone (dropzone) that accepts files dragged from the file system.
File upload is one of the few UI patterns that bridges the gap between the browser and the operating system — the user leaves the web application momentarily to interact with their OS file dialog, then returns with selected files. This hand-off creates unique UX challenges around feedback, validation, and error recovery.
Modern file upload components go far beyond the native <input type="file"> (which is notoriously difficult to style and provides minimal feedback). Production implementations include drag-and-drop zones, image previews, progress indicators, file type validation, size limits, and multi-file management.
When to use a File Upload:
- User-generated content: profile photos, document attachments, media uploads
- Form submissions requiring file attachments (insurance claims, job applications)
- Bulk data import (CSV, spreadsheets, data files)
- Media-rich applications (photo galleries, video platforms, design tools)
When NOT to use a File Upload:
- Text-only input — use a Text Input or textarea
- Selecting from predefined assets — use an asset picker or gallery component
- Pasting content — support clipboard paste separately from file upload
- Real-time capture — use camera/microphone APIs directly
Preview drag-state animations (file entering the dropzone, hover effects, drop confirmation) with the Animation & Easing Tool. Verify that dropzone borders and icons meet non-text contrast requirements with the Contrast Checker.
Variants
Interaction Variants
| Variant | Description | Best For |
|---|---|---|
| Button | A styled button that opens the system file picker. Simplest form. | Single file, inline forms, mobile-first |
| Dropzone | A dashed-border area that accepts dragged files and also acts as a click target for the file picker. | Desktop-primary, multi-file, media uploads |
| Inline Dropzone | Compact dropzone that sits within a form row, not spanning full width. | Comment attachments, chat file sharing |
| Avatar Upload | Circular or square area with camera icon overlay. Preview replaces the placeholder after selection. | Profile photos, logos, thumbnails |
| Full-Page Drop | The entire page becomes a drop target when a file is dragged over the browser window. | Cloud storage apps, design tools |
Display Variants
| Variant | Description | Common In |
|---|---|---|
| File List | Uploaded files shown as a list with name, size, type icon, and remove button. | Document uploads, email attachments |
| Thumbnail Grid | Uploaded images shown as a grid of thumbnails with overlaid remove/edit actions. | Photo galleries, product images |
| Single Preview | Single large preview replacing the dropzone content. | Profile photo, cover image |
| Compact Chip | Uploaded file shown as a removable chip/tag. | Chat attachments, inline forms |
Size Variants
| Size | Dimensions | Use Case |
|---|---|---|
| sm | 120px height, compact padding | Inline attachments, table cells |
| md | 180px height | Default for most forms |
| lg | 240px+ height | Primary upload area, hero-level upload zones |
| Full | 100% of parent height | Full-page dropzones, dedicated upload views |
Properties
File Upload Properties
| Property | Type | Default | Description |
|---|---|---|---|
accept | string | — | Accepted file types as MIME types or extensions: "image/*", ".pdf,.docx", "video/mp4" |
multiple | boolean | false | Allow selecting multiple files |
maxSize | number | — | Maximum file size in bytes. Display human-readable format (e.g., "Max 10 MB"). |
maxFiles | number | — | Maximum number of files (multi mode) |
minSize | number | — | Minimum file size in bytes (rare, for preventing empty files) |
disabled | boolean | false | Disables the upload control |
onDrop | (files: File[], rejections: FileRejection[]) => void | — | Callback when files are dropped or selected. Receives both accepted and rejected files. |
onUploadProgress | (file: File, progress: number) => void | — | Progress callback per file (0–100) |
onRemove | (file: File) => void | — | Callback when a file is removed from the list |
validator | (file: File) => FileRejection | null | — | Custom validation function per file |
noClick | boolean | false | Disable click-to-open (drag-only mode) |
noDrag | boolean | false | Disable drag-and-drop (click-only mode) |
preview | boolean | true | Show image previews for uploaded image files |
variant | 'button' | 'dropzone' | 'avatar' | 'dropzone' | Visual variant |
children | ReactNode | — | Custom content inside the dropzone |
FileRejection Type
interface FileRejection {
file: File;
errors: Array<{
code: "file-too-large" | "file-too-small" | "file-invalid-type" | "too-many-files";
message: string;
}>;
}
Token Mappings
Design Token Mappings
| Token Category | Token Example | File Upload Usage |
|---|---|---|
| Color – Border | --color-neutral-300 | Dropzone dashed border (rest state) |
| Color – Border Active | --color-primary-500 | Dropzone border when file is dragged over |
| Color – Border Error | --color-error-500 | Border when invalid file type is dragged over |
| Color – Background | --color-neutral-50 | Dropzone background (rest) |
| Color – Background Active | --color-primary-50 | Dropzone background when file is hovering |
| Color – Icon | --color-neutral-400 | Upload icon in the dropzone |
| Color – Icon Active | --color-primary-500 | Upload icon when active/dragging |
| Color – Progress | --color-primary-600 | Upload progress bar fill |
| Color – Error Text | --color-error-600 | Error message and rejected file text |
| Color – Success | --color-success-600 | Completed upload checkmark |
| Spacing – Padding | --space-6 (24px) | Dropzone internal padding |
| Spacing – Gap | --space-3 (12px) | Gap between icon, text, and file list |
| Border Radius | --radius-lg (12px) | Dropzone corners. Preview with Border Radius Generator. |
| Border Style | dashed | Dropzone border is dashed, not solid |
| Border Width | 2px | Dashed border thickness |
| Shadow | --shadow-sm | File list item elevation |
| Animation | --duration-normal (200ms), ease-out | Drag enter/leave transitions. Preview with Animation & Easing Tool. |
| Typography | --font-size-sm, --color-neutral-500 | Helper text ("Drag & drop or click to upload") |
States
Interactive States
| State | Visual Treatment | Behavior |
|---|---|---|
| Rest | Dashed border, upload icon, helper text ("Drag & drop files or click to browse"). Muted colors. | Awaiting interaction. |
| Hover | Border color darkens slightly, cursor pointer. Subtle background tint. | Mouse is over the dropzone. |
| Drag Over (Valid) | Primary-color border (solid, not dashed), primary background tint, icon animates (bounce or pulse). "Drop files here" text. | A valid file type is being dragged over the zone. |
| Drag Over (Invalid) | Error-color border, error background tint, ✕ icon. "File type not accepted" text. | An invalid file type is being dragged over. |
| Uploading | Progress bar below each file. Percentage or bytes transferred shown. Cancel button available. | Files are being uploaded to the server. |
| Upload Complete | Green checkmark, file name, file size. Remove button. | Upload succeeded. |
| Upload Failed | Red ✕ icon, error message ("Upload failed — network error"). Retry button. | Upload encountered an error. |
| File Rejected | Inline error below the rejected file: "File exceeds 10 MB limit" or "Invalid file type." | Validation failed before upload. |
| Disabled | Muted border, muted background, no interaction. Cursor not-allowed. | Upload is not currently available. |
| Max Files Reached | Dropzone becomes inactive, helper text updates: "Maximum 5 files reached." Remove a file to re-enable. | Multi-file limit hit. |
Drag Animation
The drag-over state transition is critical for usability. When a file enters the dropzone:
- Border transitions from dashed neutral to solid primary over ~150ms.
- Background fades to a primary tint (
--color-primary-50). - The upload icon can scale up slightly (
transform: scale(1.1)) or animate a subtle bounce using@keyframes. - Text changes from "Drag & drop or click" to "Drop files here."
On drag leave, reverse the transition. On drop, show a brief "flash" confirmation (background flashes to --color-success-50 for 300ms) before showing the file list.
Preview these drag-state animations with the Animation & Easing Tool.
Accessibility
Accessibility Requirements
File upload has unique accessibility challenges because it involves OS-level file dialogs that are outside your control, drag-and-drop which is mouse-only by default, and dynamically appearing file lists.
Keyboard Access (WCAG 2.1.1 Keyboard):
- The dropzone must be focusable (
tabindex="0") and activatable withEnterorSpaceto open the file picker. - The hidden
<input type="file">is triggered programmatically — never rely on the native input being visible or focusable. - Each uploaded file's remove button must be keyboard-accessible.
- Retry buttons on failed uploads must be keyboard-accessible.
- If drag-and-drop is the only upload method, keyboard users are locked out. Always provide a click-to-browse fallback (WCAG 2.1.1).
Screen Reader Announcements (WCAG 4.1.3 Status Messages):
- When files are added, announce: "3 files added. 2 uploading." Use
aria-live="polite"on a status region. - When upload completes, announce: "[filename] uploaded successfully."
- When upload fails, announce: "[filename] upload failed. [error reason]."
- When a file is rejected, announce the rejection reason immediately.
- When a file is removed, announce: "[filename] removed."
Labels and Instructions (WCAG 1.3.1, 3.3.2):
- The dropzone needs an accessible name:
aria-label="Upload documents"or an associated<label>. - Display accepted file types and size limits as visible text and as
aria-describedbyon the dropzone: "Accepted formats: JPG, PNG, PDF. Maximum size: 10 MB."
Non-Text Contrast (WCAG 1.4.11):
- The dashed border of the dropzone must have ≥3:1 contrast against the page background. Light gray dashes on a white background often fail — use the Contrast Checker.
- The upload icon inside the dropzone must also meet 3:1 contrast.
- Progress bar track and fill must be distinguishable (3:1 between fill and track).
Focus Visibility (WCAG 2.4.7 / 2.4.13):
- The dropzone must show a visible focus ring when focused via keyboard.
- Focus ring should be at least 2px solid with an offset.
Error Prevention (WCAG 3.3.4):
- If uploading is irreversible (e.g., submitting a form), provide confirmation before final submission.
- Allow users to review and remove files before committing.
- Show file previews so users can verify they selected the correct files.
Target Size (WCAG 2.5.8):
- Remove buttons on individual file items must be at least 24×24px.
- The dropzone click target should be generous — the entire zone, not just the text or icon.
Drag and Drop Accessibility:
- Drag-and-drop is inherently inaccessible to keyboard and screen reader users. It's an enhancement, never the sole method.
- Announce the drag state to screen readers using
aria-liveregions, though in practice, drag-and-drop is primarily a visual/pointer interaction. - Some implementations use
aria-dropeffect(deprecated in ARIA 1.1) — avoid this. Simply ensure the click fallback works and announce results.
Usage Guidelines
Do's
- Always provide a click-to-browse fallback alongside drag-and-drop. Mobile users and keyboard users can't drag.
- Show accepted file types and size limits upfront — "JPG, PNG up to 5 MB" in the dropzone text. Don't let users discover limits only through error messages.
- Display upload progress for each file — a progress bar with percentage. Users need to know if a 50 MB video is 10% or 90% done.
- Allow cancellation — a cancel button during upload and a remove button after upload.
- Validate before uploading — check file type and size client-side before sending to the server. Don't waste bandwidth uploading a 500 MB file only to reject it.
- Show image previews — for image uploads, display a thumbnail immediately using
URL.createObjectURL()orFileReader. Users need to verify they picked the right photo. - Provide error recovery — retry buttons for network failures, clear error messages for validation failures.
Don'ts
- Don't auto-upload without indication — if files upload immediately on drop, make this clear. If they upload on form submit, make that clear too.
- Don't lose files on navigation — if the user accidentally navigates away during upload, warn them (
beforeunloadevent). - Don't show raw MIME types in errors — "image/jpeg" means nothing to most users. Say "JPG image" or "PDF document."
- Don't restrict silently — if you limit to 5 files and the user drops 8, explain which were rejected and why. Don't just silently take the first 5.
- Don't use "Choose File" as the only label — the native input text is unhelpful. Provide context: "Upload resume (PDF, DOCX — max 10 MB)."
- Don't forget mobile — on mobile, the file picker should offer camera, photo library, and file options. Ensure your
acceptattribute triggers the right OS picker.
Progress & Feedback Best Practices
- For small files (<1 MB), upload is near-instant — show a brief check animation instead of a progress bar.
- For large files, show: file name, progress bar, percentage, upload speed (e.g., "2.4 MB/s"), and estimated time remaining.
- For multi-file upload, show individual progress per file and an overall progress indicator.
- Chunk large files (>50 MB) and support resumable uploads to handle network interruptions gracefully.
Code Snippets
<!-- File Upload: Dropzone -->
<div class="dropzone" id="dropzone" tabindex="0" role="button" aria-label="Upload files. Accepted: JPG, PNG, PDF. Max 10 MB each.">
<input
type="file"
id="file-input"
accept=".jpg,.jpeg,.png,.pdf"
multiple
hidden
aria-hidden="true"
/>
<div class="dropzone__content">
<svg class="dropzone__icon" width="40" height="40" viewBox="0 0 40 40" aria-hidden="true">
<path d="M20 6v20M12 14l8-8 8 8" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 28v4a2 2 0 002 2h24a2 2 0 002-2v-4" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<p class="dropzone__text">
<strong>Drag & drop files</strong> or <button type="button" class="dropzone__browse" onclick="document.getElementById('file-input').click()">browse</button>
</p>
<p class="dropzone__hint">JPG, PNG, PDF — max 10 MB each</p>
</div>
</div>
<ul id="file-list" class="file-list" aria-label="Uploaded files"></ul>
<div id="upload-status" aria-live="polite" class="sr-only"></div>
<style>
.dropzone {
border: 2px dashed var(--color-neutral-300);
border-radius: var(--radius-lg, 12px);
background: var(--color-neutral-50);
padding: 2rem;
text-align: center;
cursor: pointer;
transition: border-color 150ms ease, background-color 150ms ease;
outline: none;
}
.dropzone:hover {
border-color: var(--color-neutral-400);
background: var(--color-neutral-100);
}
.dropzone:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
.dropzone.drag-over {
border-color: var(--color-primary-500);
border-style: solid;
background: var(--color-primary-50);
}
.dropzone.drag-invalid {
border-color: var(--color-error-500);
border-style: solid;
background: var(--color-error-50);
}
.dropzone__icon {
color: var(--color-neutral-400);
margin-bottom: 0.75rem;
transition: color 150ms ease, transform 150ms ease;
}
.dropzone.drag-over .dropzone__icon {
color: var(--color-primary-500);
transform: scale(1.1);
}
.dropzone__text {
font-size: 0.875rem;
color: var(--color-neutral-700);
margin: 0;
}
.dropzone__browse {
background: none;
border: none;
color: var(--color-primary-600);
text-decoration: underline;
cursor: pointer;
font-size: inherit;
padding: 0;
}
.dropzone__hint {
font-size: 0.75rem;
color: var(--color-neutral-400);
margin: 0.25rem 0 0;
}
.file-list {
list-style: none;
padding: 0;
margin: 0.75rem 0 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--color-white);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-md, 8px);
font-size: 0.8125rem;
}
.file-item__progress {
height: 4px;
background: var(--color-neutral-200);
border-radius: 2px;
flex: 1;
overflow: hidden;
}
.file-item__progress-fill {
height: 100%;
background: var(--color-primary-600);
transition: width 200ms ease;
}
.file-item__remove {
background: none;
border: none;
color: var(--color-neutral-400);
cursor: pointer;
padding: 0.25rem;
min-width: 24px;
min-height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
</style>
<script>
const dropzone = document.getElementById("dropzone");
const fileInput = document.getElementById("file-input");
const fileList = document.getElementById("file-list");
const statusRegion = document.getElementById("upload-status");
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
dropzone.addEventListener("click", () => fileInput.click());
dropzone.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fileInput.click();
}
});
dropzone.addEventListener("dragover", (e) => {
e.preventDefault();
dropzone.classList.add("drag-over");
});
dropzone.addEventListener("dragleave", () => {
dropzone.classList.remove("drag-over", "drag-invalid");
});
dropzone.addEventListener("drop", (e) => {
e.preventDefault();
dropzone.classList.remove("drag-over", "drag-invalid");
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener("change", () => {
handleFiles(fileInput.files);
fileInput.value = "";
});
function handleFiles(files) {
let added = 0;
[...files].forEach((file) => {
if (file.size > MAX_SIZE) {
statusRegion.textContent = file.name + " rejected: exceeds 10 MB limit.";
return;
}
addFileItem(file);
added++;
});
if (added > 0) {
statusRegion.textContent = added + " file(s) added.";
}
}
function addFileItem(file) {
const li = document.createElement("li");
li.className = "file-item";
const sizeStr = (file.size / 1024 / 1024).toFixed(1) + " MB";
li.innerHTML =
'<span class="file-item__name">' + file.name + ' (' + sizeStr + ')</span>' +
'<button class="file-item__remove" aria-label="Remove ' + file.name + '">✕</button>';
li.querySelector(".file-item__remove").addEventListener("click", () => {
li.remove();
statusRegion.textContent = file.name + " removed.";
});
fileList.appendChild(li);
}
</script>import React, { useState, useRef, useCallback, useId } from "react";
interface UploadedFile {
file: File;
id: string;
progress: number;
status: "pending" | "uploading" | "complete" | "error";
error?: string;
}
interface FileUploadProps {
accept?: string;
multiple?: boolean;
maxSize?: number;
maxFiles?: number;
disabled?: boolean;
label?: string;
hint?: string;
onFilesAdded?: (files: File[]) => void;
onFileRemoved?: (file: File) => void;
}
export function FileUpload({
accept,
multiple = false,
maxSize = 10 * 1024 * 1024,
maxFiles,
disabled = false,
label = "Upload files",
hint,
onFilesAdded,
onFileRemoved,
}: FileUploadProps) {
const id = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<UploadedFile[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [announcement, setAnnouncement] = useState("");
const formatSize = (bytes: number) =>
bytes < 1024 * 1024
? (bytes / 1024).toFixed(0) + " KB"
: (bytes / 1024 / 1024).toFixed(1) + " MB";
const validate = useCallback(
(file: File): string | null => {
if (maxSize && file.size > maxSize) return `File exceeds ${formatSize(maxSize)} limit`;
if (accept) {
const types = accept.split(",").map((t) => t.trim());
const ext = "." + file.name.split(".").pop()?.toLowerCase();
const matches = types.some(
(t) =>
t === ext ||
t === file.type ||
(t.endsWith("/*") && file.type.startsWith(t.replace("/*", "/")))
);
if (!matches) return "File type not accepted";
}
return null;
},
[accept, maxSize]
);
const addFiles = useCallback(
(incoming: FileList | File[]) => {
const arr = Array.from(incoming);
const accepted: File[] = [];
const rejected: string[] = [];
arr.forEach((f) => {
const err = validate(f);
if (err) {
rejected.push(`${f.name}: ${err}`);
} else if (maxFiles && files.length + accepted.length >= maxFiles) {
rejected.push(`${f.name}: max ${maxFiles} files reached`);
} else {
accepted.push(f);
}
});
const newFiles = accepted.map((f) => ({
file: f,
id: crypto.randomUUID(),
progress: 0,
status: "pending" as const,
}));
setFiles((prev) => [...prev, ...newFiles]);
onFilesAdded?.(accepted);
const msgs: string[] = [];
if (accepted.length) msgs.push(`${accepted.length} file(s) added`);
if (rejected.length) msgs.push(`Rejected: ${rejected.join("; ")}`);
setAnnouncement(msgs.join(". "));
},
[files.length, maxFiles, validate, onFilesAdded]
);
const removeFile = (uploadedFile: UploadedFile) => {
setFiles((prev) => prev.filter((f) => f.id !== uploadedFile.id));
onFileRemoved?.(uploadedFile.file);
setAnnouncement(`${uploadedFile.file.name} removed`);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (!disabled) setIsDragOver(true);
};
const handleDragLeave = () => setIsDragOver(false);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
if (!disabled) addFiles(e.dataTransfer.files);
};
const handleClick = () => {
if (!disabled) inputRef.current?.click();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.key === "Enter" || e.key === " ") && !disabled) {
e.preventDefault();
inputRef.current?.click();
}
};
return (
<div>
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-label={label}
aria-describedby={hint ? `${id}-hint` : undefined}
aria-disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
border: `2px ${isDragOver ? "solid" : "dashed"} ${isDragOver ? "var(--color-primary-500)" : "var(--color-neutral-300)"}`,
borderRadius: "var(--radius-lg, 12px)",
background: isDragOver ? "var(--color-primary-50)" : "var(--color-neutral-50)",
padding: "2rem",
textAlign: "center",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
transition: "border-color 150ms ease, background-color 150ms ease",
outline: "none",
}}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
hidden
aria-hidden="true"
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = "";
}}
/>
<p style={{ margin: 0, fontSize: "0.875rem", color: "var(--color-neutral-700)" }}>
<strong>Drag & drop files</strong> or click to browse
</p>
{hint && (
<p id={`${id}-hint`} style={{ margin: "0.25rem 0 0", fontSize: "0.75rem", color: "var(--color-neutral-400)" }}>
{hint}
</p>
)}
</div>
{files.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, margin: "0.75rem 0 0", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{files.map((f) => (
<li
key={f.id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.5rem 0.75rem",
background: "var(--color-white)",
border: "1px solid var(--color-neutral-200)",
borderRadius: "var(--radius-md, 8px)",
fontSize: "0.8125rem",
}}
>
<span>{f.file.name} ({formatSize(f.file.size)})</span>
<button
type="button"
aria-label={`Remove ${f.file.name}`}
onClick={() => removeFile(f)}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--color-neutral-400)",
minWidth: 24,
minHeight: 24,
}}
>
✕
</button>
</li>
))}
</ul>
)}
<div aria-live="polite" aria-atomic="true" style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0,0,0,0)" }}>
{announcement}
</div>
</div>
);
}Design Systems
Design System Implementations
Material Design 3 doesn't define a dedicated file upload component — it recommends using a filled or outlined button labeled "Attach file" or "Upload" with a paperclip or upload icon. For drag-and-drop, Material guidelines suggest a dashed container with the primary color as the drag-over indicator. MUI (Material UI for React) doesn't include a built-in upload component, so developers typically combine Button, LinearProgress, and custom dropzone logic, or use third-party libraries like react-dropzone.
Ant Design provides an Upload component with type="drag" for dropzone mode, listType="picture-card" for image grid uploads, and listType="text" for file lists. It supports beforeUpload for client-side validation, customRequest for custom upload logic, showUploadList configuration, and the Dragger sub-component for full drag-and-drop zones. Ant's upload manages the entire lifecycle: selection → validation → progress → success/error, with built-in retry on failure.
Chakra UI doesn't include a file upload primitive. The community typically uses react-dropzone combined with Chakra's Box, Text, and Progress components for custom implementations. Chakra's styling system makes it straightforward to build themed dropzones.
Radix UI and Headless UI don't provide file upload primitives — this is an application-level concern rather than a primitive UI pattern.
react-dropzone is the de facto standard library for React file upload. It provides a useDropzone hook that returns getRootProps() and getInputProps() for declarative dropzone setup, plus isDragActive, isDragAccept, isDragReject states for visual feedback. It handles file type validation, size validation, max file limits, and directory uploads. It does NOT handle the actual HTTP upload — that's your responsibility.
Uppy is a full-featured upload framework (by Transloadit) that provides a dashboard UI, drag-and-drop, webcam capture, screen recording, Google Drive / Dropbox imports, chunked uploads, resumable uploads (tus protocol), and progress tracking. It's the most complete upload solution available but adds significant bundle size.
FilePond is another popular file upload library with a polished UI: animated file items, image preview and editing, responsive design, and plugins for image cropping, validation, and file encoding. It has adapters for React, Vue, Angular, and Svelte.
For drag-state animation timing, experiment with the Animation & Easing Tool. Verify that your dropzone border contrast meets 3:1 against the background using the Contrast Checker.