Inputs
Dropzone
A drag-and-drop file picker built on a native file input — per-file accept, size and count validation with visible rejection reasons.
Preview in your theme
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/dropzone.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Dropzone" component (lucide-react
icons; no react-dropzone — native drag & drop plus a native
<input type="file">).
Contract
- Export a forwardRef<HTMLDivElement> component extending div props with
onDrop/onDragEnter/onDragLeave/onDragOver omitted; spread the rest on the
root and merge className via cn().
- Props: onFiles(files: File[]) => void — fired with the FULL current
accepted list after every add or remove (selection only, never uploads);
accept?: string (native accept syntax: "image/*", ".pdf", "video/mp4",
comma-separated); multiple?: boolean (default true; false = single-file
mode where a new pick replaces the old one); maxFiles?: number;
maxSize?: number (bytes); disabled?: boolean.
- Internal state: accepted File[] plus the latest batch's rejections as
{name, reason}[].
Behavior
- The prompt area is a real <button type="button"> that .click()s a hidden
native file input — Enter/Space and screen-reader semantics come free; the
input keeps accept/multiple so the OS picker pre-filters.
- Reset input.value after reading the FileList so re-picking the same file
re-fires change.
- Drag: dragenter/dragleave fire once per child node crossed, so track a
depth counter in a ref — highlight while depth > 0, clear on drop or when
the counter returns to zero. preventDefault on dragover to permit
dropping — also while disabled, where dataTransfer.dropEffect = "none"
shows the no-drop cursor: an uncancelled dragover hands the drop to the
browser, which opens the file and navigates away from unsaved form state.
Only react to drags whose dataTransfer.types include "Files".
- Validate per file, in order: accept match (extension ".png", wildcard
"image/*", or exact MIME) → maxSize → remaining capacity (maxFiles minus
already-accepted; in single-file mode the new file replaces instead of
consuming capacity). Passing files append to the list and onFiles fires
with the updated list; failing files render as reason lines inside the
zone ("logo.svg — file type not accepted", "video.mp4 — larger than
2 MB", "d.png — file limit reached (max 3)"). Each new selection replaces
the previous batch's rejection lines.
- Every accepted file renders as a row: name (truncated) + human-readable
size (B/KB/MB/GB) + a remove button; removing updates the list and
re-fires onFiles. The pressed button unmounts with its row, so an effect
after the list changes moves focus to the remove button now at that index,
else the new last row's, else the browse button — never to <body>.
- disabled: trigger and remove buttons get the disabled attribute,
dragenter/dragleave early-return, dragover and drop still preventDefault but
add nothing, the zone dims.
Rendering & styling
- Semantic tokens only. Idle zone: border border-dashed rounded-xl
bg-muted/30; drag-over: border-primary bg-primary/5 (also exposed as a
data-dragover attribute for consumer styling); file rows: bg-background +
border; rejection lines: text-destructive; icon and captions:
text-muted-foreground. No hex/oklch anywhere.
- UploadCloud icon above a headline ("Drag & drop files here, or click to
browse" → "Drop files to add them" while dragging) and a hint caption
derived from accept/maxSize/maxFiles ("image/* · up to 2 MB · max 3
files").
- focus-visible ring on the trigger and remove buttons; remove buttons get
aria-label="Remove <name>"; rejection lines live inside an always-mounted
aria-live="polite" container so screen readers announce them; the color
transition is motion-reduce:transition-none.
Customization levers
- Controlled list: swap the internal useState for value/onFilesChange props
when a form library owns the files — the validation pipeline and rendering
stay identical.
- Real upload: keep transport in the consumer — const fd = new FormData();
files.forEach(f => fd.append("files", f));
fetch("/api/upload", { method: "POST", body: fd }). The component never
uploads.
- Single-file mode: multiple={false} for avatar/document pickers — new picks
replace, no capacity juggling.
- Rejection tone: text-destructive is the only error token; remap it (e.g.
to a warning token) without touching validation logic.
- Density: the p-4 zone padding, py-8 prompt padding and rounded-xl radius
are the layout knobs; compact forms read fine at p-3/py-6/rounded-lg.
- Hint caption: derived from the constraint props — override the copy or
drop the line freely; validation does not depend on it.Concepts
- Selection, not transport — the component owns picking, validating and listing;
onFileshands the current accepted list to the consumer, whose own submit logic performs the actual upload. - Native input delegation — a real
<input type="file">stays in the DOM and a real<button>triggers it, so keyboard activation, focus rings and the OS picker's accept pre-filter all come for free. - Drag-depth counter —
dragenter/dragleavefire once per child node crossed; counting depth in a ref and clearing the highlight only at zero kills the flicker a boolean toggle produces. - Per-file gating — each incoming file passes accept → size → capacity checks in order; the first failure becomes a visible reason line instead of a silent drop.
- Append with capacity — reselecting adds to the existing list until
maxFilesis reached; removing a file frees capacity, and single-file mode replaces instead of appending.