Inputs

File Dropzone

A drag-and-drop upload area — the magnetic dashed border leans toward the cursor and marches like a marquee, and the file list carries ring progress indicators.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add file-dropzone.en

New project or installation trouble? Start here →

File Dropzone is a React drag and drop file upload component built with Tailwind CSS and Motion. While a file hovers, the dashed border shifts toward the cursor and flows, and the file list below shows each upload's status with an SVG progress ring.

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

0.12
Active edited usageDefault props
<FileDropzone/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

When to use

  • Data import pages in an admin panel, such as uploading CSV, Excel or invoice PDFs.
  • Profile photo or ID document uploads that allow a single image file.
  • Cloud drive or asset library pages where uploading is the main action.
  • Attachment fields in forms that need per-file progress, success and failure.
  • Not a good fit: an attachment row above a chat input with thumbnail chips and drag reordering; use AI Attachment Tray instead.

Installation

npx webberui@latest add https://webberui.com/r/file-dropzone.en.json

Or, once registries are configured in components.json, install it as @webberui/file-dropzone.

Usage

import { FileDropzone } from "@/components/ui/file-dropzone";

<FileDropzone
  accept="image/*,.pdf"
  maxFiles={4}
  maxSize={10 * 1024 * 1024}
  onFilesAdded={(files) => console.log(files)}
/>

Wiring up a real upload

Without an upload prop, the component shows the animation with simulated progress; to connect a backend, pass an async function and report progress:

<FileDropzone
  upload={async (file, onProgress) => {
    await uploadToServer(file, {
      onUploadProgress: (e) => onProgress(e.loaded / e.total),
    });
  }}
/>

resolve shows the success checkmark; reject shows the failure state.

Examples

Upload to a presigned URL

Request a presigned URL from your backend, upload straight to object storage with XMLHttpRequest, and pass loaded / total to onProgress.

import { FileDropzone } from "@/components/ui/file-dropzone";

async function uploadToStorage(file: File, onProgress: (p: number) => void) {
  const res = await fetch("/api/upload-url", {
    method: "POST",
    body: JSON.stringify({ name: file.name, type: file.type }),
  });
  const { url } = await res.json();

  await new Promise<void>((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total);
    xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(String(xhr.status))));
    xhr.onerror = () => reject(new Error("network"));
    xhr.open("PUT", url);
    xhr.setRequestHeader("Content-Type", file.type);
    xhr.send(file);
  });
}

<FileDropzone accept=".csv,.xlsx" maxSize={20 * 1024 * 1024} upload={uploadToStorage} />

Single avatar upload

With multiple={false} a new file replaces the old one; use onReject to tell users why a file was not accepted.

import * as React from "react";
import { FileDropzone, type FileDropzoneRejectReason } from "@/components/ui/file-dropzone";

const reasons: Record<FileDropzoneRejectReason, string> = {
  type: "Please upload a JPG or PNG image",
  size: "Images must be under 2 MB",
  count: "You can upload only one image",
};

export function AvatarUpload() {
  const [error, setError] = React.useState<string | null>(null);

  return (
    <div className="max-w-sm">
      <FileDropzone
        label="Drop your avatar here, or click to choose"
        accept="image/jpeg,image/png"
        multiple={false}
        maxSize={2 * 1024 * 1024}
        onFilesAdded={() => setError(null)}
        onReject={(_, reason) => setError(reasons[reason])}
        upload={(file, onProgress) => uploadAvatar(file, onProgress)}
      />
      {error && <p className="mt-2 text-sm text-red-600">{error}</p>}
    </div>
  );
}

Turn off the magnetic effect

strength={0} keeps the border still during a drag, which suits dense form layouts.

<FileDropzone strength={0} label="Drop the contract PDF" accept=".pdf" />

Proximity magnet and following glow

magnet="proximity" listens for drags across the whole window instead: once a file is dragged within proximityRadius (180px by default) of the zone edge, the pull starts and grows as it gets closer (up to 10px, toward the cursor). The border scales gradually from 1 to 1.01, reaching 1.025 once inside the zone, while a blurred glow follows the cursor and fades in over 200ms. It suits pages with a single upload target where users should see where to drop as they drag.

<FileDropzone
  magnet="proximity"
  proximityRadius={220}
  accept="image/*"
  rejectMessage={(file, reason) =>
    reason === "type" ? `${file.name} is not an image` : `${file.name} can't be added`
  }
/>

Props

PropTypeDefaultDescription
onFilesAdded(files: File[]) => void—Fires when files pass validation and are added to the list
upload(file: File, onProgress: (p: number) => void) => Promise<void>—Custom upload logic; onProgress reports 0–1. When omitted, progress is simulated
onRemove(file: File) => void—Fires when a file is removed from the list
onReject(file: File, reason: "type" | "size" | "count") => void—Fires when a file fails validation
acceptstring—Accepted file types (same as the native accept); validation applies to drops as well
multiplebooleantrueAllow multiple files; when false, a new file replaces the old one
maxFilesnumber—Maximum number of files in the list
maxSizenumber—Maximum size of a single file (bytes)
strengthnumber0.12Magnetic strength; border offset = the cursor's distance from centre × strength (center mode only)
magnet"center" | "proximity""center"Magnet mode: center offsets only while hovering; proximity pulls as a dragged file approaches and shows a following glow
proximityRadiusnumber180Sensing radius for proximity mode (px); within this distance from the zone edge the pull grows linearly
rejectMessage(file: File, reason: "type" | "size" | "count") => string—Custom text announced to screen readers when validation fails; uses built-in messages when omitted
labelstring"Drag and drop files here, or click to select"Main text of the drop area
disabledbooleanfalseDisables the whole drop area

How it works

  • Magnetic border: while a drag hovers, the dashed border offsets toward the cursor on a spring (capped at 10px) and scales up slightly, while the dashes march like a marquee
  • Dragging in and out of child elements fires dragenter / dragleave repeatedly, so a depth counter internally prevents the border state from flickering
  • The progress ring advances smoothly through SVG stroke-dashoffset driven by a spring, popping a checkmark on completion and showing a warning on failure
  • accept validation supports extensions (.pdf), wildcards (image/*), and full MIME types, and applies to both drops and picks
  • The input value is cleared after a selection, so the same file can be picked again
  • proximity mode listens for dragover on window and measures the shortest distance from the cursor to the zone's rectangle; the pull changes linearly within the sensing radius, and offset and scale follow a spring with stiffness: 280, damping: 24, mass: 0.65, returning to rest when the drag leaves the window, drops, or is cancelled

Accessibility

  • The drop area is role="button", takes Tab focus, and opens the file picker with Enter / Space
  • The progress ring carries role="progressbar" and aria-valuenow, and the success / failure icons have matching aria-labels
  • The number of files uploading is announced to screen readers through aria-live="polite"
  • Validation failures (type / size / count) and upload failures are announced immediately through a separate aria-live="assertive" region; customise the text with rejectMessage
  • When the user has "reduce motion" enabled at the system level, the magnetic offset, marquee, and enter/exit animations are disabled, and the progress ring jumps straight to the current value; proximity mode attaches no window listener and shows no glow

FAQ

What happens if I don't pass upload?

The component simulates upload progress with a timer so you can preview the animation; no request is sent. In production pass upload: a resolved promise shows the success check and a rejected one shows the error state.

Does it work with the Next.js App Router?

Yes. The source starts with "use client", so import it from a Server Component page as usual. Your upload function can call a Route Handler or a Server Action.

How does accept validation work?

It supports extensions (.pdf), wildcards (image/*) and full MIME types (image/png), separated by commas. Files are checked in order for type, size (maxSize, in bytes) and count (maxFiles), for both picking and dropping, and onReject fires for any that fail.

Is it usable with a keyboard and screen readers?

Yes. The drop area is a role="button": Tab to it and press Enter or Space to open the file picker. Progress rings have role="progressbar", and the number of uploading files is announced through aria-live. With "reduce motion" enabled, the magnetic shift, marching border and enter/exit animations are turned off.

What are the dependencies?

motion, lucide-react, clsx and tailwind-merge. You can skip the CLI and copy the source from the Manual tab.