Dropzone

Drag-and-drop file input built on the native HTML5 drag-drop API plus a visually-hidden <input type='file'> for keyboard + screen-reader access. Filters by accept/maxSize/maxFiles before emitting.

Installation

pnpm
pnpm dlx @hex-core/cli add dropzone

Usage

tsx
import { Dropzone } from "@/components/ui/dropzone"

Image upload

Filter to images only with a 5MB cap

tsx
import { useState } from "react";
import { Dropzone } from "@/components/ui/dropzone";

export function Example() {
  const [files, setFiles] = useState<File[]>([]);
  return (
    <>
      <Dropzone
        accept="image/*"
        maxSize={5 * 1024 * 1024}
        onFilesSelected={(picked) => setFiles((f) => [...f, ...picked])}
        aria-label="Upload images"
      />
      <ul>{files.map((f) => <li key={f.name}>{f.name}</li>)}</ul>
    </>
  );
}

Custom body via render prop

Take full control of the drop area visuals

tsx
import { Dropzone } from "@/components/ui/dropzone";

export function Example() {
  return (
    <Dropzone aria-label="Upload" onFilesSelected={(f) => console.log(f)}>
      {({ isDragOver }) => (
        <span>{isDragOver ? "Release to upload" : "Drop a file or click"}</span>
      )}
    </Dropzone>
  );
}

API Reference

PropTypeDefaultDescription
onFilesSelected
functionCallback fired with the accepted File[] on pick or drop.
onFilesRejected
functionCallback fired with the rejected File[] when files fail accept/maxSize/maxFiles, OR when multiple={false} and extras were sliced off. Receives the rejected File[]. Use to surface 'wrong type' / 'too large' toasts.
accept
stringAccept attribute forwarded to the hidden file input. Three forms: MIME type ('application/pdf'), MIME wildcard ('image/*'), or extension with leading dot ('.csv'). Extension matching is suffix-based (mirrors HTML5) — '.csv' will match 'notes.md.csv' as well.
multiple
booleantrueAllow multiple files. With `multiple={false}`, the first accepted file flows to onFilesSelected and any extras flow to onFilesRejected so consumers can toast them.
maxFiles
numberCap on the number of accepted files. Excess files are dropped silently — surface in your handler if needed.
maxSize
numberMaximum size per file in bytes. Files over the cap are filtered before onFilesSelected fires.
disabled
booleanfalseDisable interaction (click + drop + keyboard).
children
objectRender override for the body. Pass a node, or a function receiving { isDragOver, isDisabled, openFileDialog } for layout control.
aria-labelrequired
stringRequired accessible name for the drop area.

AI Guidance

When to use

Use for any file upload UX where drag-drop is helpful (image uploaders, CSV import, attachment pickers). Built on native HTML5 + a hidden <input type='file'> so keyboard and screen-reader users get the same affordance.

When not to use

Don't use for camera/microphone capture (use <input type='file' capture> directly or a media-specific component). Don't use for chunked/resumable uploads — Dropzone only emits File[]; pair with a real upload pipeline (tus, S3 multipart). Don't use for clipboard paste image upload — listen on document for that.

Common mistakes

  • Forgetting aria-label — without it the drop area is announced as just 'button' to screen readers
  • Setting accept='image' (missing the slash or wildcard) — must be a MIME type, MIME prefix with /*, or a .extension
  • Forgetting that extension matching is suffix-based — '.csv' will match 'notes.md.csv'. Acceptable (mirrors HTML5) but document upstream if business logic requires strict suffix-only
  • Treating maxFiles as enforcement — files past the cap are dropped silently; if business rules require it, validate again on submit
  • Forgetting to clear the file input value after a successful upload — Dropzone resets the hidden input automatically; if you wrap it, do the same
  • Calling preventDefault on parent containers' onDragOver — without it the browser opens the file when dropped on the window outside the dropzone
  • Treating empty drops as no-op silently when the user expected feedback — pair with onFilesRejected to surface filter failures

Accessibility

The drop area is a role='button' div with tabIndex=0 and the required aria-label; Enter and Space open the file dialog. The file input is visually-hidden but live in the DOM so keyboard focus + assistive-tech file pickers work. Drag-state is exposed via data-drag-over for CSS-only state styling. When disabled, the drop area is removed from the tab order and aria-disabled is set.

Related components

Token budget: 1500