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…
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; skip it when disabled so the browser shows a no-drop cursor.
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.
- disabled: trigger and remove buttons get the disabled attribute, drag
handlers early-return, 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.