Skip to content

File Upload

Enables users to select and upload files through drag-and-drop or file browser.

  • File Input
  • Dropzone
  • Upload

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

VariantDescriptionBest For
ButtonA styled button that opens the system file picker. Simplest form.Single file, inline forms, mobile-first
DropzoneA 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 DropzoneCompact dropzone that sits within a form row, not spanning full width.Comment attachments, chat file sharing
Avatar UploadCircular or square area with camera icon overlay. Preview replaces the placeholder after selection.Profile photos, logos, thumbnails
Full-Page DropThe entire page becomes a drop target when a file is dragged over the browser window.Cloud storage apps, design tools

Display Variants

VariantDescriptionCommon In
File ListUploaded files shown as a list with name, size, type icon, and remove button.Document uploads, email attachments
Thumbnail GridUploaded images shown as a grid of thumbnails with overlaid remove/edit actions.Photo galleries, product images
Single PreviewSingle large preview replacing the dropzone content.Profile photo, cover image
Compact ChipUploaded file shown as a removable chip/tag.Chat attachments, inline forms

Size Variants

SizeDimensionsUse Case
sm120px height, compact paddingInline attachments, table cells
md180px heightDefault for most forms
lg240px+ heightPrimary upload area, hero-level upload zones
Full100% of parent heightFull-page dropzones, dedicated upload views

Properties

File Upload Properties

PropertyTypeDefaultDescription
acceptstring—Accepted file types as MIME types or extensions: "image/*", ".pdf,.docx", "video/mp4"
multiplebooleanfalseAllow selecting multiple files
maxSizenumber—Maximum file size in bytes. Display human-readable format (e.g., "Max 10 MB").
maxFilesnumber—Maximum number of files (multi mode)
minSizenumber—Minimum file size in bytes (rare, for preventing empty files)
disabledbooleanfalseDisables 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
noClickbooleanfalseDisable click-to-open (drag-only mode)
noDragbooleanfalseDisable drag-and-drop (click-only mode)
previewbooleantrueShow image previews for uploaded image files
variant'button' | 'dropzone' | 'avatar''dropzone'Visual variant
childrenReactNode—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 CategoryToken ExampleFile Upload Usage
Color – Border--color-neutral-300Dropzone dashed border (rest state)
Color – Border Active--color-primary-500Dropzone border when file is dragged over
Color – Border Error--color-error-500Border when invalid file type is dragged over
Color – Background--color-neutral-50Dropzone background (rest)
Color – Background Active--color-primary-50Dropzone background when file is hovering
Color – Icon--color-neutral-400Upload icon in the dropzone
Color – Icon Active--color-primary-500Upload icon when active/dragging
Color – Progress--color-primary-600Upload progress bar fill
Color – Error Text--color-error-600Error message and rejected file text
Color – Success--color-success-600Completed 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 StyledashedDropzone border is dashed, not solid
Border Width2pxDashed border thickness
Shadow--shadow-smFile list item elevation
Animation--duration-normal (200ms), ease-outDrag enter/leave transitions. Preview with Animation & Easing Tool.
Typography--font-size-sm, --color-neutral-500Helper text ("Drag & drop or click to upload")

States

Interactive States

StateVisual TreatmentBehavior
RestDashed border, upload icon, helper text ("Drag & drop files or click to browse"). Muted colors.Awaiting interaction.
HoverBorder 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.
UploadingProgress bar below each file. Percentage or bytes transferred shown. Cancel button available.Files are being uploaded to the server.
Upload CompleteGreen checkmark, file name, file size. Remove button.Upload succeeded.
Upload FailedRed ✕ icon, error message ("Upload failed — network error"). Retry button.Upload encountered an error.
File RejectedInline error below the rejected file: "File exceeds 10 MB limit" or "Invalid file type."Validation failed before upload.
DisabledMuted border, muted background, no interaction. Cursor not-allowed.Upload is not currently available.
Max Files ReachedDropzone 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:

  1. Border transitions from dashed neutral to solid primary over ~150ms.
  2. Background fades to a primary tint (--color-primary-50).
  3. The upload icon can scale up slightly (transform: scale(1.1)) or animate a subtle bounce using @keyframes.
  4. 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 with Enter or Space to 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-describedby on 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-live regions, 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() or FileReader. 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 (beforeunload event).
  • 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 accept attribute 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

html
<!-- 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>
tsx
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.

Data InputFile InputDropzoneUpload