Loading…
Loading…
Enables users to select and upload files through drag-and-drop or file browser.
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:
When NOT to use a File Upload:
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.
| 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 |
| 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 | 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 |
| 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 |
interface FileRejection {
file: File;
errors: Array<{
code: "file-too-large" | "file-too-small" | "file-invalid-type" | "too-many-files";
message: string;
}>;
}
| 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") |
| 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. |
The drag-over state transition is critical for usability. When a file enters the dropzone:
--color-primary-50).transform: scale(1.1)) or animate a subtle bounce using @keyframes.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.
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):
tabindex="0") and activatable with Enter or Space to open the file picker.<input type="file"> is triggered programmatically — never rely on the native input being visible or focusable.Screen Reader Announcements (WCAG 4.1.3 Status Messages):
aria-live="polite" on a status region.Labels and Instructions (WCAG 1.3.1, 3.3.2):
aria-label="Upload documents" or an associated <label>.aria-describedby on the dropzone: "Accepted formats: JPG, PNG, PDF. Maximum size: 10 MB."Non-Text Contrast (WCAG 1.4.11):
Focus Visibility (WCAG 2.4.7 / 2.4.13):
Error Prevention (WCAG 3.3.4):
Target Size (WCAG 2.5.8):
Drag and Drop Accessibility:
aria-live regions, though in practice, drag-and-drop is primarily a visual/pointer interaction.aria-dropeffect (deprecated in ARIA 1.1) — avoid this. Simply ensure the click fallback works and announce results.URL.createObjectURL() or FileReader. Users need to verify they picked the right photo.beforeunload event).accept attribute triggers the right OS picker.<!-- 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>
);
}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.