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…

"use client"

import * as React from "react"
import { FileIcon, UploadCloud, X } from "lucide-react"
import { cn } from "@/lib/utils"

interface Rejection {
  name: string
  reason: string
}

export interface DropzoneProps
  extends Omit<
    React.HTMLAttributes<HTMLDivElement>,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/dropzone.json

Prompt

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; onFiles hands 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 counterdragenter/dragleave fire 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 maxFiles is reached; removing a file frees capacity, and single-file mode replaces instead of appending.

On This Page